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

  • Intermediate
  • 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

  • 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:

Interactive · message by message The same callback=? call on jQuery 3.7.1 and on 4.0.0 Step through the widget's two calls on 3.7.1, then on 4.0.0, and watch what each server receives.
The same callback=? call on jQuery 3.7.1 and on 4.0.0, step 5 of 5. Messages so far: The rates widget to Its own server: callback=jQuery…_…; The rates widget to The rates API: a script element; The rates widget to Its own server: callback=? as it is; The rates widget to The rates API: Origin: https://shop.example; The rates widget to The rates API: dataType: 'jsonp'.The rates widget$.getJSON(…callback=?)Its own serveranswered JSONThe rates APIa script element againcallback=jQuery…_…a script elementcallback=? as it isOrigin: https://shop.exampledataType: 'jsonp'

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

Every step as text
  1. 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.
  2. 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.
  3. 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. 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.
  5. 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':

The rates widget: jsonp.js

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>
What the widget logged and the servers received, on 4.0.0 and 3.7.1 JavaScript · jsonp.mjs
// 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

  1. Question 1 of 2 On jQuery 4.0.0, what does $.getJSON("/srv/rates?callback=?") send to the shop's own server?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A JSON request with callback=? in the URL as it is

    The upgrade guide says auto-promotion was removed because the developer may not know the page runs another domain's code; a JSONP request now needs dataType "jsonp".

  2. Question 2 of 2 Why is a JSONP answer from another origin a risk that a JSON answer is not?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is code that runs in the page with all the page's powers

    JSON is data that the page parses; JSONP is a program the page runs. CORS lets the server share data without the page running its code.

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:

The rates API's allowlist: cors.mjs JavaScript · 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:

The allowlist and the copying version, origin by origin JavaScript · cors-check.mjs
// 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:

Interactive · answer and compare What should the API send in Access-Control-Allow-Origin? Answer two questions about the API you are moving off JSONP; the table marks what fits it.
Do the requests carry cookies or other credentials?
Must sites you do not know be able to read the answer?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three CORS answers compared on unknown pages, cookies and caches.
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

  1. Question 1 of 2 An API answers every request with Access-Control-Allow-Origin set to the request's Origin and Access-Control-Allow-Credentials: true. Which page may read a signed-in user's data?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Any page on any origin, including https://notshop.example and pages with Origin null

    OWASP's HTML5 Security Cheat Sheet says not to return the Origin header without checks; compare it with a list of the origins you trust.

  2. Question 2 of 2 A rates API needs the user's cookies to answer. Why can it not send Access-Control-Allow-Origin: *?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Browsers refuse * for a request with credentials: the answer must name the origin

    For public data without cookies, * is fine; for anything else, name the exact origin from a list, and send Vary: Origin so that caches keep the answers apart.

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:

The rating widget: scripts.js

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>
What scripts.js logged and the server received, on 4.0.0 and 3.7.1 JavaScript · scripts.mjs
// 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:

Interactive · stage by stage How jQuery 4 sends a request for a script Step through the page's four calls and see which way each one leaves the column.
How jQuery 4 sends a request for a script, step 5 of 5. Showing A request for a script, scriptAttrs given?, headers given?, Another origin?, Async, A script element, XMLHttpRequest. In focus: A script element, XMLHttpRequest.scriptAttrsheaderscross-originA request for a script$.getScript() or 'script'scriptAttrs given?yes: a script elementheaders given?yes: XMLHttpRequestAnother origin?yes: a script elementAsyncyes: a script elementA script elementsrc=…, allowed by 'self'XMLHttpRequestthen the code runs inline

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

Every step as text
  1. 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.
  2. $.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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 3 $.ajax("/widgets/rating.js") with no dataType: what happens on jQuery 4.0.0?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: done() gets the code as a string, and the script does not run

    Use $.getScript(url) or dataType "script" to fetch and run code; the upgrade guide extended the 3.0 rule for other origins to the page's own origin.

  2. Question 2 of 3 A page with script-src 'self' loads a widget with $.ajax({ url: "/widgets/map.js", dataType: "script", headers: { "X-Widget": "map" } }). Why does the widget not run, and what fixes it?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: The headers force XMLHttpRequest and an inline script, which the policy blocks; drop the headers or use scriptAttrs

    jQuery 4 uses a script element for async requests, which a strict policy allows, unless headers are set: a script element cannot send headers.

  3. Question 3 of 3 How did the server tell that $.getScript() on jQuery 4.0.0 used a script element?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: The request came from <script> and had no X-Requested-With header

    A server that relied on X-Requested-With for script requests sees it no longer on jQuery 4, a change to watch for in server logs during an upgrade.

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:

The upload form: formdata.js JavaScript · shop/formdata.js
// 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' });
})();
What the server received from formdata.js, on 4.0.0 and 3.7.1 JavaScript · formdata.mjs
// 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

  1. Question 1 of 2 The same $.ajax({ method: "POST", data: formData }) call runs on 3.7.1. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It throws a TypeError before any request is sent

    On jQuery 3, FormData needs processData: false and contentType: false; jQuery 4 sets both itself, and the settings still work there.

  2. Question 2 of 2 What does jQuery 4 do with data: new Blob([csv]) and contentType: "text/csv"?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Sends the CSV bytes as the body, labelled text/csv

    jQuery 4 sends any data that is not a string, a plain object or an array as it is, which covers Blob, ArrayBuffer and FormData. Keep the contentType for a Blob: jQuery 4 drops its form Content-Type only for FormData.

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:

Timeline · 9 events in order How jQuery's Ajax changed, release by release Walk through the releases; the 4.0 events are what an upgrade has to check.
  1. 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.

  2. 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. 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().

  4. 3.5

    Event shorthands deprecated

    .ajaxStart(), .ajaxError() and the other four shorthands are deprecated in favour of .on("ajaxStart", …); jQuery 4 still has them.

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

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

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

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

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

Four files of an old 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 over the theme JavaScript · audit.mjs
// 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.

Content Security Policy (CSP) Generator Write the script-src policy your upgraded pages will run under, then load each widget with it.

Checkpoint

Check: Finding the calls an upgrade changes

  1. One question audit.mjs reported rates-widget.js:6 event-shorthand for $(document).ajaxError(fn). What does it mean for the upgrade?

    Choose one answer.

    Show the answer: One question

    Answer: Nothing breaks on 4.0: the shorthand is deprecated since 3.5 and still there, so change it to .on() when convenient

    Sort audit findings by what breaks: the promotion and the scripts that stop running come first; deprecated methods and settings that can go come after.

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.

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.

  1. Question 1 of 10 On jQuery 4, how do you still make a JSONP request?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: With dataType: "jsonp"

    The upgrade guide: to trigger a JSONP request, it is now required to specify dataType "jsonp". The better move is CORS.

  2. Question 2 of 10 After the upgrade, $.getJSON("https://api.example.in/rates?callback=?") works for the shop but fails for a partner's site on another origin. What is the likely cause?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: It is now a CORS request, and the API's allowlist does not name the partner's origin

    Moving off JSONP means the API decides who may read its answers: add the partner's origin to its list if it should, never a copy of any Origin.

  3. Question 3 of 10 Why does cors.mjs send Vary: Origin on every answer?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: So that a cache does not give one origin's answer, with its CORS headers, to another

    An answer that depends on a request header should name that header in Vary, or a shared cache may serve it to the wrong page.

  4. Question 4 of 10 Should an API add the origin "null" to its allowlist so that a widget in a sandboxed frame can read it?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: No: many unrelated pages send Origin null, so allowing it lets all of them read the answer

    Give the widget a real origin, or serve it from your own; never trust a value that any page can send.

  5. Question 5 of 10 Which call fetches and runs /widgets/rating.js on both jQuery 3.7.1 and 4.0.0?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: $.getScript("/widgets/rating.js")

    $.getScript() sets dataType "script", so it is not affected by either version's rule.

  6. Question 6 of 10 A page with a strict CSP must load a script that needs a nonce. Which call adds the nonce to the element jQuery 4 creates?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: $.ajax({ url, dataType: "script", scriptAttrs: { nonce: value } })

    scriptAttrs makes jQuery use a script element whatever else is set, which is also how a page passes crossorigin or integrity to it.

  7. Question 7 of 10 Why does jQuery 4 use a script element for async script requests to its own origin?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: To avoid Content Security Policy errors: a policy can allow a script src without allowing inline code

    With a policy such as script-src 'self', a same-origin script element runs and inline code does not.

  8. Question 8 of 10 A codebase must run on jQuery 3.7.1 and 4.0.0 during the upgrade. What should its FormData uploads use?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: processData: false and contentType: false, which work on both, until every page is on 4

    Keep the settings while both versions are live, then remove them, which the audit marks as formdata-settings.

  9. Question 9 of 10 Which audit findings change what a page does once it runs jQuery 4?

    Choose every answer that is right.

    Show the answer: Question 9 of 10

    Answer:

    • jsonp-promotion
    • script-not-run

    Fix what changes behaviour before the upgrade ships; clean up deprecated methods and old settings afterwards.

  10. Question 10 of 10 audit.mjs reads text. Which call can it miss?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: $.ajax(settings), where settings is built in another function

    A text search is a first pass: read the calls whose settings are computed, and add a test for the pages that matter, run on both versions.

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.

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

    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

    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?

    Model answer, Pro. First step and follow-ups

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 needs dataType: 'jsonp'.
  • CORS should name trusted origins from an exact list, never * with cookies and never a copy of the request’s Origin, with Vary: Origin on 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 through scriptAttrs.
  • FormData and Blobs go to the server as they are on jQuery 4, while jQuery 3 throws without processData: false and contentType: 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

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.