jQuery 4 (legacy code and migration to DOM APIs) Module 4 – Ajax, Deferreds and forms
Forms: val(), serialize() and jQuery Validation
.val() for every form control, .serialize() against FormData and URLSearchParams field by field, and moving jQuery Validation rules to native checks.
What you will learn
- Read and set form values with .val() and .prop() for every control type
- Compare .serialize() with FormData and URLSearchParams field by field
- Maintain jQuery Validation rules on a release without known advisories, with escapeHtml set
- Plan the move of each rule to the Constraint Validation API, with accessible error messages
Before you start
What you’ll learn · Before you start
What you will learn
- Read and set form values with .val() and .prop() for every control type
- Compare .serialize() with FormData and URLSearchParams field by field
- Maintain jQuery Validation rules on a release without known advisories, with escapeHtml set
- Plan the move of each rule to the Constraint Validation API, with accessible error messages
Before you start
On this page
A sign-up form’s .serialize() sends six fields, and the same form as a FormData sends seven: the file input, which
.serialize() never includes. Its jQuery Validation rule pattern: /[0-9]{6}/ accepts PIN 4110012, because a
RegExp rule of the plugin matches anywhere in the value, while the HTML pattern attribute must match all of it.
Form code is where small differences of this kind hide. The form pages here ran in jsdom on jQuery 4.0.0, the first
two on 3.7.1 as well, with jQuery Validation 1.22.1 for the plugin’s page. You will read and set every kind of control
with .val() and .prop(), compare the three serialisers field by field, keep jQuery Validation on a release without
known advisories, and move its rules to the browser’s own checks with messages that screen readers announce.
.val() reads the first element; a checkbox needs :checked or .prop()
.val() gets the value of the first element of a collection, and for most controls that is all there is
(.val()). Radios and checkboxes are the exception: they share a name, and .val()
returns the value attribute of the first one whether it is checked or not. A select with multiple gives an array,
empty when nothing is chosen since jQuery 3.0, where jQuery 1 and 2 gave null. The sign-up form logs what .val()
returns for each control, then sets a few:
shop/controls.js
// What .val() reads from each kind of control of the sign-up form, and what it sets.
const field = (name) => $(`#signup [name=${name}]`);
console.log(`a text input: "${field('name').val()}"`);
console.log(`the textarea: ${JSON.stringify(field('address').val())}`);
console.log(`a select: "${field('city').val()}"`);
console.log(`a select multiple, nothing chosen: ${JSON.stringify(field('langs').val())}`);
console.log(`the radio group: "${field('plan').val()}", the checked one: "${field('plan').filter(':checked').val()}"`);
console.log(`the unticked checkbox: "${field('news').val()}", .prop('checked') ${field('news').prop('checked')}, .is(':checked') ${field('news').is(':checked')}`);
let changes = 0;
$('#signup').on('change', () => changes++);
field('plan').val(['monthly']);
field('langs').val(['hi', 'ta']);
field('news').val(['yes']);
field('terms').val('accepted');
console.log(`after .val(['monthly']), .val(['hi', 'ta']) and .val(['yes']): plan "${field('plan').filter(':checked').val()}", langs ${JSON.stringify(field('langs').val())}, news ticked ${field('news').prop('checked')}`);
console.log(`after .val('accepted') on the ticked terms box: still ticked ${field('terms').prop('checked')}, its value now "${field('terms').val()}"`);
console.log(`change events from those .val() calls: ${changes}`);
field('news').prop('checked', false).trigger('change');
console.log(`after .prop('checked', false).trigger('change'): ${changes} change event`); shop/controls.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The sign-up form</title>
</head>
<body>
<form id="signup" action="/srv/register" method="post">
<label>Name <input name="name" value="Asha K"></label>
<label>Email <input type="email" name="email" value="asha@example.in"></label>
<label>Address <textarea name="address">Flat 4
MG Road</textarea></label>
<label>City <select name="city"><option>Pune</option><option selected>Chennai</option></select></label>
<label>Languages <select name="langs" multiple><option value="hi">Hindi</option><option value="ta">Tamil</option></select></label>
<label><input type="radio" name="plan" value="monthly"> Monthly</label>
<label><input type="radio" name="plan" value="yearly" checked> Yearly</label>
<label><input type="checkbox" name="news" value="yes"> Newsletter</label>
<label><input type="checkbox" name="terms" value="ok" checked> Terms</label>
<label>Document <input type="file" name="doc"></label>
<label>Branch PIN <input name="branch" value="411001" disabled></label>
<button type="submit" name="action" value="save">Save</button>
</form>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="controls.js"></script>
</body>
</html> // Prints what shop/controls.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/controls.html'); Output
a text input: "Asha K"
the textarea: "Flat 4\nMG Road"
a select: "Chennai"
a select multiple, nothing chosen: []
the radio group: "monthly", the checked one: "yearly"
the unticked checkbox: "yes", .prop('checked') false, .is(':checked') false
after .val(['monthly']), .val(['hi', 'ta']) and .val(['yes']): plan "monthly", langs ["hi","ta"], news ticked true
after .val('accepted') on the ticked terms box: still ticked true, its value now "accepted"
change events from those .val() calls: 0
after .prop('checked', false).trigger('change'): 1 change event
(jQuery 3.7.1 logged the same 10 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node controls.mjs
The radio group gave "monthly", the first radio, though "yearly" was checked; .filter(':checked').val() gave the
right answer. The unticked newsletter box gave its value "yes" all the same, so the state has to come from
.prop('checked') or .is(':checked'). Setting works the other way round: .val(['monthly']) with an array checked
the radio whose value is listed, and selected the listed options, while .val('accepted') with a plain string
changed the box’s value and left it ticked.
| Control | .val() returns |
Its state |
|---|---|---|
| text, email, textarea, select | the value | the value |
select multiple |
an array, [] when nothing is chosen |
the array |
| radio group | the first radio’s value |
.filter(':checked').val() |
| checkbox | its value, ticked or not |
.prop('checked') or .is(':checked') |
None of those calls fired a change event: the documentation says that setting a value never dispatches one, and the
run counted 0 until .trigger('change') was called. Code that recalculates a total or shows a field on change must
trigger the event after a script fills the form.
Checkpoint
Check: Reading and setting values with .val()
.serialize(), FormData and URLSearchParams send different bytes
All three start from the same list of fields that the HTML Standard calls the entry list: The list of names and values that the HTML Standard builds from a form when it is sent or turned into a FormData. Disabled fields, unticked boxes and every button except the one that submitted the form are left out.:
it skips disabled controls, unticked checkboxes and radios, and every button except the one that submitted the form,
and it gives a file input an empty File when no file is chosen
(HTML Standard).
.serialize() then leaves out file inputs and buttons altogether and writes a form body with %20 for spaces
(.serialize()). new FormData(form) keeps the files, and
new FormData(form, submitter) adds the clicked button, in Chrome 112, Firefox 111 and Safari 16.4
(browser-compat-data). Sent as multipart, a
FormData writes each line break of a value as CR LF
(HTML Standard).
new URLSearchParams(formData) turns the FormData back into a form body with the URL Standard’s encoding, + for a
space (URL Standard). The form is serialised all three
ways and then posted twice:
// The sign-up form serialised three ways, then posted twice to the test server, which logs the bodies.
const form = document.getElementById('signup');
const data = new FormData(form);
console.log(`.serialize(): ${$(form).serialize()}`);
console.log(`.serializeArray() names: ${$(form).serializeArray().map((f) => f.name).join(', ')}`);
console.log(`new FormData(form) names: ${[...data.keys()].join(', ')}`);
console.log(` doc is a ${data.get('doc').constructor.name} named "${data.get('doc').name}", the textarea ${JSON.stringify(data.get('address'))}`);
const withButton = new FormData(form, form.querySelector('[name=action]'));
console.log(`new FormData(form, submitter) adds: ${[...withButton.keys()].filter((k) => !data.has(k)).join(', ')}`);
console.log(`new URLSearchParams(new FormData(form)): ${new URLSearchParams(data)}`);
(async () => {
await $.post('/srv/register', $(form).serialize());
await $.ajax({ url: '/srv/register', method: 'POST', data, processData: false, contentType: false });
})(); // Prints what shop/serialize.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/serialize.html'); Output
.serialize(): name=Asha%20K&email=asha%40example.in&address=Flat%204%0D%0AMG%20Road&city=Chennai&plan=yearly&terms=ok
.serializeArray() names: name, email, address, city, plan, terms
new FormData(form) names: name, email, address, city, plan, terms, doc
doc is a File named "", the textarea "Flat 4\nMG Road"
new FormData(form, submitter) adds: action
new URLSearchParams(new FormData(form)): name=Asha+K&email=asha%40example.in&address=Flat+4%0AMG+Road&city=Chennai&plan=yearly&terms=ok&doc=%5Bobject+File%5D
server: POST /srv/register from XMLHttpRequest, answered 200
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
body: name=Asha+K&email=asha%40example.in&address=Flat+4%0D%0AMG+Road&city=Chennai&plan=yearly&terms=ok
server: POST /srv/register from XMLHttpRequest, answered 200
Content-Type: multipart/form-data; boundary=BOUNDARY
X-Requested-With: XMLHttpRequest
body: --BOUNDARY
Content-Disposition: form-data; name="name"
Asha K
--BOUNDARY
Content-Disposition: form-data; name="email"
asha@example.in
--BOUNDARY
Content-Disposition: form-data; name="address"
Flat 4
MG Road
--BOUNDARY
Content-Disposition: form-data; name="city"
Chennai
--BOUNDARY
Content-Disposition: form-data; name="plan"
yearly
--BOUNDARY
Content-Disposition: form-data; name="terms"
ok
--BOUNDARY
Content-Disposition: form-data; name="doc"; filename=""
Content-Type: application/octet-stream
--BOUNDARY--
(jQuery 3.7.1 logged the same 45 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node serialize.mjs
| The field | .serialize() |
new FormData(form), then URLSearchParams of it |
|---|---|---|
A space in Asha K |
Asha%20K |
Asha K in its part; Asha+K |
| The textarea’s line break | %0D%0A |
a line feed, sent as CR LF in its part; %0A |
| The file input | left out | an empty File named ""; doc=%5Bobject+File%5D |
| The clicked button | never | with the submitter, in both |
| Disabled or unticked | left out | left out, in both |
The server log adds one more step: $.post(url, $(form).serialize()) arrived as name=Asha+K and
Flat+4%0D%0AMG+Road, because $.ajax() turns %20 into + in a form body, so the bytes on the wire are not the
string .serialize() returned. A server decodes all of these to the same values; only code that compares raw strings,
such as a signature over the body or a stored test, sees the difference. Answer three questions about a form of your
own, and the table marks the serialiser that fits it:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Serialiser | File inputs | The clicked button | The same bytes as before |
|---|---|---|---|
| $(form).serialize() Sends: a form body as a string | Poor skips them: the run had no doc field at all | Poor never includes a submit button | Good what the jQuery page sent: %20 for spaces, %0D%0A for line breaks |
| new FormData(form) Sends: multipart/form-data | Good includes them, an empty File when none is chosen | Good adds it when you pass the submitter: the run added action | Poor another body type altogether, multipart with a boundary |
| URLSearchParams of a FormData Sends: a form body | Poor turns a file into the text [object File] | Mixed only if the FormData was made with the submitter | Mixed the same fields, with + for spaces and %0A for line breaks |
How the answers decide
- “Does the form have a file input?” Yes: rules out $(form).serialize() and URLSearchParams of a FormData. Only a FormData sends the file itself; .serialize() leaves it out and URLSearchParams turns it into text.
- “Must the server know which submit button was pressed?” Yes, there are several: rules out $(form).serialize() and adds a caveat to URLSearchParams of a FormData and new FormData(form). .serialize() never sends a button; a FormData does when it is made with the submitter, new FormData(form, submitter).
- “Does anything compare the raw body, such as a signature or a stored test?” Yes: rules out new FormData(form) and adds a caveat to URLSearchParams of a FormData. A check of the raw body sees + where .serialize() wrote %20 and %0A where it wrote %0D%0A; keep .serialize() or update the check.
URLSearchParams around a form with a file
Wrong: fetch(url, { method: 'POST', body: new URLSearchParams(new FormData(form)) }) for a form with a file input,
which sends the file as the text [object File]. Right: body: new FormData(form), which keeps the file.
Checkpoint
Check: .serialize(), FormData and URLSearchParams
The browser checks required, type, pattern and minlength itself
The constraint validation: The browser's check of a form's fields against their rules, such as required, pattern or type="email", before the form is sent. A failed check fires an invalid event at the field and stops the sending. More in “trigger(), custom events and native listeners” of HTML checks the rules a form states in its
attributes: required, type="email", pattern, minlength, min and max, and any message a script sets with
setCustomValidity() (HTML Standard).
Each field has a validity object with one flag per rule; checkValidity() reports whether the form is valid and
fires an invalid event at each field that is not. The registration form states its rules as attributes, and its
script fills the fields, as a test would:
shop/native.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Registration</title>
</head>
<body>
<form id="register" action="/srv/register" method="post">
<label for="email">Email</label>
<input id="email" type="email" name="email" required autocomplete="email" aria-describedby="email-error">
<p id="email-error" class="field-error"></p>
<label for="pin">PIN code</label>
<input id="pin" name="pin" required pattern="[0-9]{6}" inputmode="numeric" autocomplete="postal-code" aria-describedby="pin-error">
<p id="pin-error" class="field-error"></p>
<label for="ifsc">Bank IFSC</label>
<input id="ifsc" name="ifsc" required pattern="[A-Z]{4}0[A-Z0-9]{6}" aria-describedby="ifsc-error">
<p id="ifsc-error" class="field-error"></p>
<label for="password">Password</label>
<input id="password" type="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-error">
<p id="password-error" class="field-error"></p>
<label for="confirm">Confirm the password</label>
<input id="confirm" type="password" name="confirm" required autocomplete="new-password" aria-describedby="confirm-error">
<p id="confirm-error" class="field-error"></p>
<button>Register</button>
</form>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="native.js"></script>
</body>
</html> shop/native.js
// The registration form checked by the browser itself: required, type="email", two patterns, minlength, and one
// custom rule for the second password. The script fills the fields, as a test would.
const form = document.getElementById('register');
const { email, pin, ifsc, password, confirm } = form.elements;
const KINDS = ['valueMissing', 'typeMismatch', 'patternMismatch', 'tooShort', 'customError'];
const report = (label) => {
console.log(`${label}: form.checkValidity() is ${form.checkValidity()}`);
for (const field of [email, pin, ifsc, password, confirm]) {
const failed = KINDS.filter((kind) => field.validity[kind]);
console.log(` ${field.name}: ${failed.length ? failed.join(', ') : 'valid'}`);
}
};
const matchPasswords = () => confirm.setCustomValidity(confirm.value === password.value ? '' : 'The passwords do not match.');
const invalid = [];
let submitted = 0;
form.addEventListener('invalid', (event) => invalid.push(event.target.name), true);
form.addEventListener('submit', (event) => {
event.preventDefault();
submitted++;
});
email.value = 'asha@';
pin.value = 'PIN 411001';
ifsc.value = 'sbin0001234';
password.value = 'short';
confirm.value = 'other';
matchPasswords();
report('filled in by the script');
invalid.length = 0; // checkValidity() in report() fired invalid events of its own
form.requestSubmit();
console.log(`requestSubmit(): invalid events for ${invalid.join(', ')}; submit events: ${submitted}`);
email.value = 'asha@example.in';
pin.value = '411001';
ifsc.value = 'SBIN0001234';
password.value = 'long enough';
confirm.value = 'long enough';
matchPasswords();
report('corrected');
form.requestSubmit();
console.log(`requestSubmit(): submit events: ${submitted}`); // Prints what shop/native.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/native.html'); Output
filled in by the script: form.checkValidity() is false email: typeMismatch pin: patternMismatch ifsc: patternMismatch password: valid confirm: customError requestSubmit(): invalid events for email, pin, ifsc, confirm; submit events: 0 corrected: form.checkValidity() is true email: valid pin: valid ifsc: valid password: valid confirm: valid requestSubmit(): submit events: 1
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node native.mjs
The email asha@ failed typeMismatch, PIN 411001 failed its pattern, because the pattern attribute must match
the whole value (the pattern attribute), and
the lower-case IFSC failed the pattern [A-Z]{4}0[A-Z0-9]{6}. The second password failed the custom rule. The first
password, short, passed, though the field says minlength="8": the HTML Standard counts a value as too short only
when the last change to it was “a user edit”, never one made by a script. A test that sets .value never sees that
flag, so check lengths in the test itself.
PIN codes and IFSC codes
A PIN code has six digits (UPU),
so pattern="[0-9]{6}" with inputmode="numeric"; an IFSC has 11 characters, four letters for the bank, a 0, then
six for the branch (RBI), so pattern="[A-Z]{4}0[A-Z0-9]{6}".
A pattern checks the form of a code, not that it exists: look it up as the PIN code and IFSC finders do, and check it
again on the server.
form.requestSubmit() behaved as pressing the button does: it fired invalid at the four failing fields and no
submit event, and one submit event once every value was fixed. form.submit() skips both the constraint
validation and the submit event, which the HTML Standard’s form submission algorithm states outright, so code that
submits a form for the user calls requestSubmit(); Chrome 76, Firefox 75 and Safari 16 have it
(browser-compat-data). To style only
the fields a user has touched, the CSS selector :user-invalid waits for the user, where :invalid matches from the
start; it is in Chrome 119, Firefox 88 and Safari 16.5
(browser-compat-data).
Key idea
The browser enforces the rules a field states in its attributes and reports them in validity, but only on
requestSubmit() or a button press, and it checks minlength only for values a user typed.
Checkpoint
Check: The browser's constraint validation
jQuery Validation runs on jQuery 4 from 1.22, with two traps to check
jQuery Validation is the plugin most jQuery forms use for their rules, and ASP.NET Core MVC builds its client-side
validation on it, with Microsoft’s jQuery Unobtrusive Validation reading the rules from data-val attributes
(ASP.NET Core). Before
upgrading jQuery, check which release of the plugin a site loads. Its releases fixed advisories and added jQuery 4
support in turn; walk through them:
- 1.19.3
A regular-expression fix security
Fixes CVE-2021-21252, a regular expression that took very long on crafted input (ReDoS); every earlier release is affected.
- 1.19.4
Another ReDoS fix security
Fixes CVE-2021-43306, a ReDoS in the url2 method of the additional methods.
- 1.19.5
A third ReDoS fix security
Fixes CVE-2022-31147 in the same url2 method, which the fix of 1.19.4 had left open.
- 1.20.0
The escapeHtml option security
Adds escapeHtml against CVE-2025-3573, cross-site scripting through a message that repeats what the user typed. It is off by default: set escapeHtml: true.
- 1.21.0
Web Components
Validates fields inside Web Components, and accepts negative decimals without a leading zero.
- 1.22.0
Runs on jQuery 4 jQuery 4
Adds compatibility with jQuery 4.0.0, the first release of the plugin that supports it.
- 1.22.1
A fix for an input named id
Fixes a TypeError on forms that contain an input named id. Its package.json names jQuery ^1.7, ^2.0, ^3.1 and ^4.0 as peers, and this lesson runs it on jQuery 4.0.0.
The timeline has 7 events, from 1.19.3 to 1.22.1.
OSV lists four security advisory: A published notice of a security flaw in a package: what the flaw is, which releases it affects and which release fixes it. Databases such as OSV and the GitHub Advisory Database collect them for npm packages, jquery among them. More in “Why upgrade jQuery: CVEs, support and browsers” for releases before 1.20.0, three of them a regular expression that can be made to run for a very long time (ReDoS), and one, CVE: Common Vulnerabilities and Exposures: the public list that gives each known security flaw an identifier such as CVE-2020-11023, so that advisories, scanners and fixes all refer to the same problem. More in “jQuery in 2026: versions, support and where it runs”-2025-3573, cross-site scripting (XSS): A security flaw in which text from someone else, such as a comment or a URL parameter, reaches a page as markup or code and runs in visitors' browsers. Putting such text in with .text() or textContent, never as HTML, prevents it. More in “Changing content: html(), text(), append, remove” through a message (OSV). The release notes give 1.22.0 as the first with jQuery 4.0.0 compatibility (releases). The documentation sample of ASP.NET Core’s client-side validation still loads 1.19.3, so a layout copied from it carries three of those advisories. The plugin’s page runs 1.22.1 on jQuery 4.0.0 with the registration form’s rules, then the same message in two small forms:
shop/plugin.js
// The registration form's jQuery Validation 1.22.1 rules on jQuery 4.0.0, then the same message in two forms, one
// with escapeHtml: true.
const validator = $('#register').validate({
rules: {
email: { required: true, email: true },
pin: { required: true, pattern: /[0-9]{6}/ },
password: { required: true, minlength: 8 },
confirm: { equalTo: '#password' },
},
messages: { pin: { pattern: 'Enter the 6-digit PIN code.' } },
});
$('#email').val('asha@');
$('#pin').val('PIN 4110012');
$('#password').val('short');
$('#confirm').val('other');
console.log(`valid(): ${$('#register').valid()}`);
for (const error of validator.errorList) console.log(` ${error.element.name}, ${error.method}: ${error.message}`);
console.log(`"PIN 4110012" passed the rule /[0-9]{6}/: ${validator.element('#pin')}`);
$('#pin').rules('add', { pattern: '[0-9]{6}' });
console.log(`with the rule as the string "[0-9]{6}": ${validator.element('#pin')}`);
console.log(`the message element of email: ${$('#email-error').prop('outerHTML')}`);
console.log(`the plugin set novalidate on the form: ${$('#register').attr('novalidate') !== undefined}`);
// A message that repeats what the user typed, in the form without escapeHtml and in the form with it.
const echo = { pin: { pattern: (params, element) => `"${element.value}" is not a PIN code.` } };
$('#weak').validate({ rules: { pin: { pattern: '[0-9]{6}' } }, messages: echo });
$('#safe').validate({ rules: { pin: { pattern: '[0-9]{6}' } }, messages: echo, escapeHtml: true });
for (const id of ['weak', 'safe']) {
$(`#${id}-pin`).val('<b>4110</b>');
$(`#${id}`).valid();
const $message = $(`#${id}-pin-error`);
console.log(`${id}: the message holds ${$message.find('b').length} <b> element, and reads ${JSON.stringify($message.text())}`);
} shop/plugin.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Registration with jQuery Validation</title>
</head>
<body>
<form id="register">
<label for="email">Email</label> <input id="email" type="email" name="email">
<label for="pin">PIN code</label> <input id="pin" name="pin">
<label for="password">Password</label> <input id="password" type="password" name="password">
<label for="confirm">Confirm the password</label> <input id="confirm" type="password" name="confirm">
<button>Register</button>
</form>
<form id="weak"><label for="weak-pin">PIN code</label> <input id="weak-pin" name="pin"></form>
<form id="safe"><label for="safe-pin">PIN code</label> <input id="safe-pin" name="pin"></form>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.22.1/dist/jquery.validate.min.js" integrity="sha256-1IuP7k/g+d98/r5+D1I2X2w+xhBVJJ+shT7D1kXXiYs=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.22.1/dist/additional-methods.min.js" integrity="sha256-odWrYKD3o9Uu58Jq10+qgHhubxkOis989S7+nCFjE14=" crossorigin="anonymous"></script>
<script src="plugin.js"></script>
</body>
</html> // Prints what shop/plugin.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/plugin.html'); Output
valid(): false
email, email: Please enter a valid email address.
password, minlength: Please enter at least 8 characters.
confirm, equalTo: Please enter the same value again.
"PIN 4110012" passed the rule /[0-9]{6}/: true
with the rule as the string "[0-9]{6}": false
the message element of email: <label id="email-error" class="error" for="email">Please enter a valid email address.</label>
the plugin set novalidate on the form: true
weak: the message holds 1 <b> element, and reads "\"4110\" is not a PIN code."
safe: the message holds 0 <b> element, and reads "\"<b>4110</b>\" is not a PIN code."
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node plugin.mjs
Three rules failed, but not the PIN rule: "PIN 4110012" passed the rule /[0-9]{6}/. A RegExp rule of the plugin’s
pattern method is tested as it is, so it matches anywhere in the value, while a string rule is wrapped in ^(?:…)$;
with the string "[0-9]{6}" the same value failed. That is the first trap. The second is in the message: a message
that repeats what the user typed was inserted as HTML in the weak form, which held one <b> element, and as text in
the form with escapeHtml: true. The option arrived in 1.20.0 as the fix for CVE-2025-3573 and is off by default, so
the update alone does not switch it on (pull request 2462).
Keep messages as text
Set escapeHtml: true in $.validator.setDefaults() or every .validate() call, and never build a message from a
field’s value with HTML around it. A check such as the page’s, which counts the elements inside the message, fails
on the weak setting and passes on the fix.
Checkpoint
Check: jQuery Validation on jQuery 4
Moving the rules to the browser, with messages a screen reader hears
Most rules of jQuery Validation have an attribute that does the same: required, email is type="email",
minlength, maxlength, min, max, and pattern once it is anchored, since the attribute matches the whole value
as OWASP advises for every pattern: “Require a match of the entire value”
(Input Validation Cheat Sheet).
equalTo has none, and needs setCustomValidity(); remote stays a request to the server. The plugin itself reads
attributes such as required and minlength as rules, which allows a safe order: move the rules into attributes while
the plugin still runs, then replace its messages with your own, then remove it. Step down one submit of the ported
form:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Submit. Pressing Register, or calling form.requestSubmit(), starts the form submission. form.submit() would skip the rules and the submit event entirely.
- Our messages. The page set novalidate, so the browser shows none of its own bubbles and fires the submit event; the handler stops the real submission with preventDefault() and runs the rules itself.
- The custom rule. No attribute says "equal to the password field", so the handler calls setCustomValidity() on the second password: a non-empty message makes it invalid.
- Messages and state. For each field, the handler turns its validity flags into the page's own message, writes it into the element that aria-describedby names, and sets aria-invalid.
- The first attempt fails. Four fields failed: the email, the 5-digit PIN, the empty password and the second password. Focus moved to the first, email, and nothing was sent.
- The second attempt. With every value corrected, no field was invalid, the messages were cleared, and the form was sent: the run logged "sent".
The ported page shows its own messages: it sets novalidate, checks validity in the submit handler, writes each
message into the element that the field’s aria-describedby names, and sets aria-invalid, so a screen reader
announces the field as invalid with its message as the description
(WAI-ARIA 1.2). It submits wrong values, then right ones:
// The same rules on the browser's constraint validation: four attributes, setCustomValidity() for the second
// password, and the page's own messages, each in the element that the field's aria-describedby names.
const form = document.getElementById('register');
form.noValidate = true; // the page shows its own messages instead of the browser's bubbles
const fields = [...form.querySelectorAll('[aria-describedby]')];
const PATTERN = { pin: 'Enter the 6-digit PIN code.', ifsc: 'Enter the 11-character IFSC, such as SBIN0001234.' };
function messageFor(field) {
const v = field.validity;
if (v.valueMissing) return 'Fill in this field.';
if (v.typeMismatch) return 'Enter an email address, such as asha@example.in.';
if (v.patternMismatch) return PATTERN[field.name];
if (v.tooShort) return `Use at least ${field.minLength} characters.`;
if (v.customError) return field.validationMessage;
return '';
}
function show(field) {
const message = messageFor(field);
document.getElementById(field.getAttribute('aria-describedby')).textContent = message;
field.setAttribute('aria-invalid', String(message !== ''));
}
const matchPasswords = () => form.elements.confirm.setCustomValidity(form.elements.confirm.value === form.elements.password.value ? '' : 'The passwords do not match.');
form.addEventListener('submit', (event) => {
event.preventDefault();
matchPasswords();
fields.forEach(show);
const first = fields.find((field) => !field.validity.valid);
if (first) {
first.focus();
console.log(`not sent; focus on ${document.activeElement.name}`);
for (const field of fields) console.log(` ${field.name}: aria-invalid ${field.getAttribute('aria-invalid')}, "${document.getElementById(field.getAttribute('aria-describedby')).textContent}"`);
return;
}
console.log('sent');
});
const fill = (values) => Object.entries(values).forEach(([name, value]) => (form.elements[name].value = value));
fill({ email: 'asha@', pin: '41100', ifsc: 'SBIN0001234', password: '', confirm: 'x' });
form.requestSubmit();
fill({ email: 'asha@example.in', pin: '411001', password: 'long enough', confirm: 'long enough' });
form.requestSubmit(); // Prints what shop/port.js logged in jsdom and what the test server received (recorded.mjs).
import { show } from './show.mjs';
show('shop/port.html'); Output
not sent; focus on email email: aria-invalid true, "Enter an email address, such as asha@example.in." pin: aria-invalid true, "Enter the 6-digit PIN code." ifsc: aria-invalid false, "" password: aria-invalid true, "Fill in this field." confirm: aria-invalid true, "The passwords do not match." sent
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node port.mjs
The first attempt was not sent: four fields had a message and aria-invalid="true", the IFSC had neither, and focus
moved to the first invalid field, the email. The second attempt was sent. The page’s own message texts replace the
browser’s, whose wording depends on the browser and its language.
| jQuery Validation rule | In the browser |
|---|---|
required: true |
required |
email: true |
type="email" |
minlength: 8, maxlength: 64 |
minlength="8", maxlength="64", checked for typed values |
pattern: '[0-9]{6}', or a RegExp with ^ and $ |
pattern="[0-9]{6}" |
equalTo: '#password' |
setCustomValidity() in a handler |
remote: url |
a server check, with the answer set by setCustomValidity() |
The server checks again
OWASP’s cheat sheet asks for checks on the server for every field, “even when the browser checks the same fields”. A request can be sent without the page, so every allowlist pattern and length of the form belongs there too.
Checkpoint
Check: Porting rules with accessible messages
Practise: turn the rules into attributes
The port starts with a table like the one above, done by code for every field. Write the function that turns one field’s jQuery Validation rules into HTML attributes and lists the rules that still need a script; the sample tests include the unanchored RegExp of this lesson:
Exercise · Medium · JavaScript
Turn jQuery Validation rules into HTML attributes
A team moves its forms from jQuery Validation to the browser's constraint validation, field by field. Write toNative(rules) in rules.mjs: it takes the jQuery Validation rules of one field and returns { attributes, needsScript }, the HTML attributes that express them and the names of the rules that no attribute can express, which need setCustomValidity() or a server check:
| Rule | Becomes | | --- | --- | | required: true | the attribute required with the value '' | | email: true | type: 'email' | | minlength: n, maxlength: n | minlength and maxlength with String(n) | | min: n, max: n | min and max with String(n) | | digits: true | pattern: '[0-9]*' and inputmode: 'numeric' | | pattern: 'text' (a string) | pattern with the same text | | pattern: /…/ written ^…$, without flags | pattern with the source between ^ and $ | | pattern: /…/ without both anchors, or with flags | nothing; add 'pattern' to needsScript | | equalTo, remote, and any rule not in this table | nothing; add the rule's name to needsScript |
A rule set to false is left out. needsScript lists names in the order of the rules object. The HTML pattern attribute always matches the whole value, while a RegExp rule of jQuery Validation matches anywhere in it, which is why an unanchored one cannot become an attribute as it is. The tests pass plain rules objects.
Starter code · rules.mjs
/**
* The HTML attributes that express a field's jQuery Validation rules, and the rules that need a script.
* @param {Record<string, unknown>} rules
* @returns {{ attributes: Record<string, string>, needsScript: string[] }}
*/
export function toNative(rules) {
const attributes = {};
for (const [name, value] of Object.entries(rules)) attributes[name] = String(value);
return { attributes, needsScript: [] };
} The sample tests · rules.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { toNative } from './rules.mjs';
test('required, email and the lengths become attributes', () => {
assert.deepEqual(toNative({ required: true, email: true, minlength: 8, maxlength: 64 }), {
attributes: { required: '', type: 'email', minlength: '8', maxlength: '64' },
needsScript: [],
});
});
test('min, max and digits become attributes too', () => {
assert.deepEqual(toNative({ min: 1, max: 99, digits: true }), {
attributes: { min: '1', max: '99', pattern: '[0-9]*', inputmode: 'numeric' },
needsScript: [],
});
});
test('a string pattern stays as it is, and an anchored RegExp loses its anchors', () => {
assert.deepEqual(toNative({ pattern: '[0-9]{6}' }).attributes, { pattern: '[0-9]{6}' });
assert.deepEqual(toNative({ pattern: /^[A-Z]{4}0[A-Z0-9]{6}$/ }).attributes, { pattern: '[A-Z]{4}0[A-Z0-9]{6}' });
});
test('an unanchored or flagged RegExp needs a script', () => {
assert.deepEqual(toNative({ required: true, pattern: /[0-9]{6}/ }), { attributes: { required: '' }, needsScript: ['pattern'] });
assert.deepEqual(toNative({ pattern: /^[a-z]+$/i }), { attributes: {}, needsScript: ['pattern'] });
assert.deepEqual(toNative({ pattern: /^[0-9]{6}/ }), { attributes: {}, needsScript: ['pattern'] });
});
test('equalTo, remote and unknown rules need a script, in order', () => {
assert.deepEqual(toNative({ required: true, equalTo: '#password', remote: '/srv/check-email', creditcard: true }), {
attributes: { required: '' },
needsScript: ['equalTo', 'remote', 'creditcard'],
});
});
test('a rule set to false is left out', () => {
assert.deepEqual(toNative({ required: false, email: false, minlength: 8 }), { attributes: { minlength: '8' }, needsScript: [] });
}); A hint
Loop over Object.entries(rules) and switch on the name. For a RegExp, rule.source is the text between the slashes and rule.flags its flags: check source.startsWith('^'), source.endsWith('$') and flags === '', then source.slice(1, -1) is the attribute.
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.
To finish, work through this lesson’s own questions. They come singly, beginning with the idea you have practised least, and each answer you choose is explained:
Practice that adapts to you
Practise this lesson
10 questions about 5 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: how do you read the chosen radio button and a checkbox’s state with jQuery?
For a radio group, narrow the collection to the checked radio before reading it: $('[name=plan]:checked').val(),
since .val() on the whole group returns the first radio’s value whether it is checked or not. For a checkbox, read
the state with .prop('checked') or .is(':checked'): .val() returns its value attribute, ticked or not. To set
them, pass an array, .val(['yearly']), which checks the elements whose values are listed, or call
.prop('checked', true); neither fires a change event, so trigger it when handlers depend on it.
Two harder questions from the module’s bank build on this lesson: a message that shows the markup a user typed, and the plan for moving a large form off the plugin. Write down your first step before you read the bank’s:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A bug report says that typing <b>4110</b> into a registration form's PIN field makes its error message bold. The form uses jQuery Validation 1.22.1 with a message function that repeats the value. Explain why this happens, why it is a security issue, and how you fix it and make sure it stays fixed.
-
Senior to staff
A site has 40 forms validated by jQuery Validation, several with equalTo and remote rules, some with RegExp patterns, and screen reader users have reported that errors are hard to find. Plan the move to the browser's constraint validation without a release in which any form accepts what it refused before.
Key takeaways
.val()reads the first element, so a radio group needs:checkedand a checkbox.prop('checked'); setting with an array checks the listed values, and no setting fireschange..serialize()leaves out files and buttons and writes%20and%0D%0A; FormData keeps files and, with a submitter, the button;URLSearchParamsof a FormData writes+,%0Aand[object File].- The browser checks
required,type,patternandminlengthitself, onrequestSubmit()or a button press;submit()skips them, andminlengthapplies only to typed values. - jQuery Validation runs on jQuery 4 from 1.22.0; releases before 1.20.0 carry advisories, a RegExp
patternrule needs^and$, and messages needescapeHtml: true. - A port moves the rules into attributes, keeps
equalToinsetCustomValidity(), shows messages througharia-describedbyandaria-invalid, and leaves the server to check everything again.
Terms in this lesson
- entry list
- The list of names and values that the HTML Standard builds from a form when it is sent or turned into a FormData. Disabled fields, unticked boxes and every button except the one that submitted the form are left out.
References
- .val() (OpenJS Foundation (jQuery API documentation))
- .serialize() (OpenJS Foundation (jQuery API documentation))
- .serializeArray() (OpenJS Foundation (jQuery API documentation))
- HTML Standard: constructing the entry list (WHATWG)
- HTML Standard: the multipart/form-data encoding algorithm (WHATWG)
- HTML Standard: constraints and the form submission algorithm (WHATWG)
- HTML Standard: the minlength attribute (WHATWG)
- HTML Standard: the pattern attribute (WHATWG)
- URL Standard: the application/x-www-form-urlencoded serializer (WHATWG)
- jQuery Validation Plugin (jQuery Validation project)
- jQuery Validation releases (jQuery Validation project)
- jQuery Validation: escapeHtml option (pull request 2462) (jQuery Validation project)
- OSV: advisories for the npm package jquery-validation (OSV (Open Source Vulnerabilities))
- CVE-2025-3573 (NIST National Vulnerability Database)
- Model validation in ASP.NET Core MVC: client-side validation (Microsoft Learn)
- MDN browser-compat-data: HTMLFormElement (Mozilla)
- MDN browser-compat-data: FormData (Mozilla)
- MDN browser-compat-data: :user-invalid (Mozilla)
- Input Validation Cheat Sheet (OWASP Foundation)
- Universal Postal Union: postal addressing systems, India (Universal Postal Union)
- Reserve Bank of India: NEFT FAQ, the Indian Financial System Code (Reserve Bank of India)
- WAI-ARIA 1.2: aria-describedby and aria-invalid (W3C)
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress