jQuery 4 (legacy code and migration to DOM APIs) Module 2 – Selecting, reading and changing the page
jQuery selectors, extensions and native queries
Which jQuery selectors the browser refuses, how to rewrite :checkbox, :text, :contains() and :first as standard CSS, and how to escape ids such as price.total.
What you will learn
- Distinguish jQuery-only selector extensions from standard CSS and predict which ones querySelectorAll() refuses
- Rewrite :checkbox, :text, :input, :selected and :contains() as standard selectors or filters that find the same elements
- Replace positional selectors such as :first, :even and :gt() with .first(), .even(), .eq() and .slice()
- Select ids that contain dots, colons or a leading digit with $.escapeSelector(), CSS.escape() or getElementById()
- Write custom pseudo-selectors that work in jQuery 4, or replace them with standard attribute selectors
Before you start
What you’ll learn · Before you start
What you will learn
- Distinguish jQuery-only selector extensions from standard CSS and predict which ones querySelectorAll() refuses
- Rewrite :checkbox, :text, :input, :selected and :contains() as standard selectors or filters that find the same elements
- Replace positional selectors such as :first, :even and :gt() with .first(), .even(), .eq() and .slice()
- Select ids that contain dots, colons or a leading digit with $.escapeSelector(), CSS.escape() or getElementById()
- Write custom pseudo-selectors that work in jQuery 4, or replace them with standard attribute selectors
Before you start
On this page
$(':checkbox') finds the two checkboxes of a shop’s checkout form; document.querySelectorAll(':checkbox') on the
same page throws a SyntaxError. jQuery’s documentation files 26 selectors under “jQuery Extensions”, and legacy code
is full of them: each one works until the day the code moves to the browser’s own querySelectorAll().
Every example below is that checkout page, run in jsdom (a browser-like DOM for Node.js) under jQuery 4.0.0 and then
3.7.1, and its scripts log which selectors the DOM’s querySelectorAll() accepted and which ones jQuery matched. You
will rewrite extensions as standard selectors or methods that find the same elements, avoid the traps in :text,
:checked, :contains() and :even, escape ids such as price.total, and keep custom pseudo-selectors working on
jQuery 4.
jQuery asks the browser first, and matches the rest itself
The checkout page has a form with two checkboxes, two lists of dishes and a line with the total. Its script,
engine.js, puts a spy on the document’s querySelectorAll(): a wrapper that passes every call on to the real method
and notes each selector the method accepted and each one it refused. Then it asks jQuery for five selectors and logs
how many elements came back and which engine found them:
checkout/engine.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Checkout</title>
</head>
<body>
<h1>Checkout</h1>
<form id="checkout">
<input name="name" placeholder="Your name">
<input type="text" name="city" placeholder="City">
<label><input type="checkbox" name="gift" checked> Gift wrap</label>
<label><input type="checkbox" name="express"> Express delivery</label>
<select name="slot">
<option>Morning</option>
<option selected>Evening</option>
</select>
<textarea name="note"></textarea>
<button type="submit">Place order</button>
<input type="button" value="Clear">
</form>
<ul class="menu" id="meals">
<li data-stock="0">Thali</li>
<li data-stock="4">Biryani</li>
<li data-stock="0">Dosa</li>
</ul>
<ul class="menu" id="snacks">
<li data-stock="12">Samosa</li>
<li data-stock="0">Vada pav</li>
</ul>
<p class="total">Total due: ₹420</p>
<div id="price.total">420</div>
<div id="order:42">Order 42</div>
<div id="1st-visit">Welcome back</div>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="engine.js"></script>
</body>
</html> checkout/engine.js
// engine.js: which selectors querySelectorAll() accepted, and which ones jQuery matched with its own engine.
// A spy wraps the document's querySelectorAll() and notes every selector it accepted and every one it refused.
const accepted = new Set();
const refused = new Set();
const native = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function (selector) {
try {
const found = native.call(this, selector);
accepted.add(selector);
return found;
} catch (error) {
refused.add(selector);
throw error;
}
};
for (const selector of ['input[type=checkbox]', ':checkbox', 'ul:has(li)', 'li:first', 'p:contains(Total)']) {
const found = $(selector).length;
const by = refused.has(selector) ? "jQuery's own engine" : accepted.has(selector) ? 'querySelectorAll()' : 'a lookup without querySelectorAll()';
console.log(`${selector}: ${found} found by ${by}`);
}
try {
document.querySelectorAll(':checkbox');
} catch (error) {
console.log(`document.querySelectorAll(':checkbox') throws ${error.name}`);
} // Prints what checkout/engine.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1 (recorded.mjs).
import { show } from './show.mjs';
show('checkout/engine.html'); Output
input[type=checkbox]: 2 found by querySelectorAll()
:checkbox: 2 found by jQuery's own engine
ul:has(li): 2 found by querySelectorAll()
li:first: 1 found by jQuery's own engine
p:contains(Total): 1 found by jQuery's own engine
document.querySelectorAll(':checkbox') throws SyntaxError
(jQuery 3.7.1 logged the same 6 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node engine.mjs
Every one of the five selectors went to querySelectorAll() first. The DOM’s engine knows only the selectors of the
CSS standard, so it answered input[type=checkbox] and ul:has(li), and threw for :checkbox, li:first and
p:contains(Total). jQuery caught each error and matched the selector with its own engine: the code of the Sizzle
project, which jQuery 3.7 moved into jQuery itself (3.7.0 release notes).
Both versions logged the same lines. Step through one standard selector and one extension:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A selector string. The page's script calls $() with a selector. Whatever the string holds, jQuery has to find the elements, and it tries the cheapest way first.
- The shortcut. A selector that is only an id, a tag name or a class goes straight to the DOM lookups that take exactly that, such as getElementById(). :checkbox is none of these.
- Ask the browser. Every other selector goes to querySelectorAll() first. For input[type=checkbox] the browser answers with the two checkboxes of the form.
- Refused. :checkbox is not CSS, so querySelectorAll() throws a SyntaxError. jQuery catches the error and notes that this selector needs its own engine.
- jQuery matches it. jQuery's own engine, the Sizzle code that jQuery 3.7 moved into jQuery, reads :checkbox and returns the same two checkboxes. The page sees no error.
- Without jQuery. Code moved to document.querySelectorAll() keeps only the browser engine. Every extension that jQuery used to match on its own now throws the SyntaxError instead.
The two engines also explain jQuery’s own advice. Queries that use an extension “cannot take advantage of the
performance boost” of querySelectorAll(), so the documentation suggests selecting with standard CSS first and
narrowing the result with .filter() (jQuery Extensions).
The document.querySelectorAll(':checkbox') line of the output is the other consequence: code that calls
document.querySelectorAll() itself has no
second engine to fall back on, and every extension in it becomes an exception.
Key idea
jQuery tries a selector on the browser’s querySelectorAll() first and quietly matches extensions with its own engine; code without jQuery gets the SyntaxError instead.
Checkpoint
Check: Which engine matches a selector
Form and text extensions have standard rewrites, with traps
Most selector extension: A selector that jQuery understands but CSS does not, such as :checkbox, :first or :contains(). jQuery matches it with its own engine; the browser's querySelectorAll() throws a SyntaxError for it. in legacy code name kinds of form field: :checkbox,
:text, :input, :button, :selected. Each has a standard equivalent, but a word-for-word swap is wrong for some
of them. rewrites.js runs on the same page; it logs each extension, then its rewrite, and says whether both found the
same elements:
// rewrites.js: each jQuery extension next to the standard selector that replaces it, and the elements each one finds.
// A short name for each element: a form field's name, a button's text, an option's text, else its tag.
function label(el) {
if (el.name) return el.name;
if (el.localName === 'input') return `"${el.value}"`;
if (el.localName === 'button' || el.localName === 'option') return `"${el.textContent.trim()}"`;
return el.localName;
}
function compare(extension, standard) {
const ours = $(extension).toArray().map(label);
const theirs = [...document.querySelectorAll(standard)].map(label);
console.log(`${extension} -> ${ours.length}: ${ours.join(', ')}`);
const same = ours.join() === theirs.join();
console.log(` ${standard} -> ${theirs.length}${same ? ', the same elements' : `: ${theirs.join(', ')}`}`);
}
compare(':checkbox', 'input[type=checkbox]');
compare(':text', 'input[type=text]');
compare(':text', 'input[type=text], input:not([type])');
compare(':input', 'input, select, textarea, button');
compare(':button', 'button, input[type=button]');
compare(':header', 'h1, h2, h3, h4, h5, h6');
compare(':selected', 'option:checked');
// :checked is standard CSS, and it matches a selected option as well as a ticked box.
console.log(`:checked -> ${$(':checked').toArray().map(label).join(', ')}`);
// :contains() has no CSS equivalent: it matches every element whose text holds the words, ancestors included.
console.log(`:contains(Total) -> ${$(':contains(Total)').toArray().map(label).join(', ')}`);
const paragraphs = [...document.querySelectorAll('p')].filter((p) => p.textContent.includes('Total'));
console.log(` p elements whose text includes Total -> ${paragraphs.map(label).join(', ')}`); // Prints what checkout/rewrites.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1 (recorded.mjs).
import { show } from './show.mjs';
show('checkout/rewrites.html'); Output
:checkbox -> 2: gift, express input[type=checkbox] -> 2, the same elements :text -> 2: name, city input[type=text] -> 1: city :text -> 2: name, city input[type=text], input:not([type]) -> 2, the same elements :input -> 8: name, city, gift, express, slot, note, "Place order", "Clear" input, select, textarea, button -> 8, the same elements :button -> 2: "Place order", "Clear" button, input[type=button] -> 2, the same elements :header -> 1: h1 h1, h2, h3, h4, h5, h6 -> 1, the same elements :selected -> 1: "Evening" option:checked -> 1, the same elements :checked -> gift, "Evening" :contains(Total) -> html, body, p p elements whose text includes Total -> p (jQuery 3.7.1 logged the same 17 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node rewrites.mjs
| jQuery extension | Standard rewrite | What to watch |
|---|---|---|
:checkbox |
input[type=checkbox] |
Nothing: the same two boxes |
:text |
input[type=text], input:not([type]) |
An input without type is a text field |
:input |
input, select, textarea, button |
Every kind of form field |
:button |
button, input[type=button] |
Both kinds of button |
:header |
h1, h2, h3, h4, h5, h6 |
All six heading levels |
:selected |
option:checked |
Selected options |
:contains(text) |
a filter on textContent |
No CSS equivalent; ancestors match too |
:visible, :hidden |
el.checkVisibility() |
Needs a page the browser has laid out |
Three traps hide among these rewrites. The name field has no type attribute, and :text has matched such inputs since jQuery
1.5.2, because their type is text by default (:text), so
input[type=text] found one field where :text found two. :checked is not an extension at all: it is CSS, and it
matches a selected option as well as a ticked box, which is why $(':checked') returned the gift-wrap box and the
Evening option. To count ticked boxes, write input:checked.
Matching every element that holds the words
Wrong: $(':contains(Total)') to find the total line. It matched html, body and the paragraph, because the text
of every ancestor includes the words; it is also case-sensitive (:contains()).
Right: name the element, p:contains(Total), and in code without jQuery filter a standard selection by its text, as
the last line of rewrites.js does.
:visible needs a word of its own. Since jQuery 3, an element is :visible when it has a layout box, even one of zero
width and height, and elements with visibility: hidden or opacity: 0 count as visible because they still take up
space (:visible). jQuery’s definition is one line of its source,
!!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length)
(hiddenVisibleSelectors.js). The
closest native test is el.checkVisibility(), which returns false for an element without a box, such as one with
display: none (MDN). jsdom lays nothing
out, so the page does not run :visible: there, every element would count as hidden. Try it in a real browser with
the playground at the end of this lesson.
Checkpoint
Check: Standard rewrites of extensions
Positional selectors count across the whole set
:first, :last, :eq(), :even, :odd, :lt() and :gt() are positional selector: A jQuery selector extension that picks elements by their position in the whole matched set, counted from 0: :first, :last, :eq(), :even, :odd, :lt() and :gt(). They are deprecated since jQuery 3.4 in favour of methods such as .first() and .eq().: they pick elements by their position in everything the selector found, counted from 0 across the
whole page. CSS’s :first-child and :nth-child() count from 1 inside each parent. The two kinds look alike in code
and part ways as soon as a page has more than one list, as the checkout page has: three meals and two snacks. Step
through the selectors on the five items:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- One set of five. $('li') finds the five items in document order: the three meals, then the two snacks. Positional selectors and methods count positions 0 to 4 across this whole set.
- li:first. :first keeps one element for the whole page, the one at position 0: Thali. .first() returns the same item.
- li:first-child. CSS counts inside each parent instead: :first-child matches the first child of each list, Thali and Samosa.
- li:even. :even keeps positions 0, 2 and 4 of the set: Thali, Dosa and Vada pav, wherever they sit on the page.
- li:nth-child(odd). :nth-child(odd) counts from 1 and starts again in each list: the 1st and 3rd meal and the 1st snack, so Samosa takes the place of Vada pav.
- .even(). The method .even() numbers the set as :even does and returns Thali, Dosa and Vada pav, with no extension in the selector.
- li:gt(2) and .slice(3). :gt(2) keeps the positions above 2, Samosa and Vada pav. .slice(3) starts at position 3 and returns the same two items.
The page’s script runs the same selectors, each next to its CSS look-alike or its method:
// positions.js: positional selectors count across every element the selector found; CSS counts inside each parent.
const texts = ($set) => $set.toArray().map((li) => li.textContent).join(', ') || 'nothing';
const show = (what, $set) => console.log(`${what} -> ${texts($set)}`);
show('li:first', $('li:first'));
show('li:first-child', $('li:first-child'));
show(".first() of $('li')", $('li').first());
show('li:last', $('li:last'));
show('li:last-child', $('li:last-child'));
show('li:even', $('li:even'));
show('li:nth-child(odd)', $('li:nth-child(odd)'));
show(".even() of $('li')", $('li').even());
show('li:eq(1)', $('li:eq(1)'));
show(".eq(1) of $('li')", $('li').eq(1));
show('li:gt(2)', $('li:gt(2)'));
show(".slice(3) of $('li')", $('li').slice(3)); // Prints what checkout/positions.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1, and what jQuery Migrate 4.0.2
// added when the same page loaded it (recorded.mjs).
import { show, showAgainst } from './show.mjs';
show('checkout/positions.html');
console.log('With jQuery Migrate 4.0.2 on the page:');
showAgainst('checkout/positions-migrate.html', 'checkout/positions.html'); Output
li:first -> Thali
li:first-child -> Thali, Samosa
.first() of $('li') -> Thali
li:last -> Vada pav
li:last-child -> Dosa, Vada pav
li:even -> Thali, Dosa, Vada pav
li:nth-child(odd) -> Thali, Dosa, Samosa
.even() of $('li') -> Thali, Dosa, Vada pav
li:eq(1) -> Biryani
.eq(1) of $('li') -> Biryani
li:gt(2) -> Samosa, Vada pav
.slice(3) of $('li') -> Samosa, Vada pav
(jQuery 3.7.1 logged the same 12 lines)
With jQuery Migrate 4.0.2 on the page:
JQMIGRATE: Migrate is installed with logging active, version 4.0.2
(and the same 12 lines as before)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node positions.mjs
li:even returned Thali, Dosa and Vada pav, the positions 0, 2 and 4 of the set, and no CSS selector gives that:
:nth-child(odd) starts counting again in the snacks list and picks Samosa instead. The documentation itself warns
that the count from 0 means, counter-intuitively, that :even selects the first, third and fifth element
(:even).
jQuery 3.4 deprecated these selectors because “anything you can do with positional selectors, you can do with positional methods instead” (3.4.0 release notes):
| Positional selector | Method on the set |
|---|---|
li:first, li:last |
$('li').first(), $('li').last() |
li:eq(n) |
$('li').eq(n) |
li:even, li:odd |
$('li').even(), $('li').odd() (jQuery 3.5 and later) |
li:lt(n) |
$('li').slice(0, n) |
li:gt(n) |
$('li').slice(n + 1) |
On 4.0.0 the selectors still work, and jQuery Migrate 4.0.2 logged nothing about them, only its start-up line. Migrate will not find them for you; the code has to be searched, which is what this lesson’s exercise does.
Key idea
Positional selectors number the whole set from 0; move each one to the method of the same name, and :lt() and :gt() to .slice().
Checkpoint
Check: Positional selectors and methods
An id with a dot or a colon must be escaped
Ids made by templates and form builders often hold characters that mean something in CSS: price.total, order:42,
item[3]. Written into a selector, #price.total means an element whose id is price and whose class is total.
escape.js selects the page’s two such ids, and one that begins with a digit, in three ways:
// escape.js: ids with characters that mean something in CSS, selected three ways.
const count = (what, n) => console.log(`${what} -> ${n} found`);
count("$('#price.total')", $('#price.total').length);
const price = $.escapeSelector('price.total');
console.log(`$.escapeSelector('price.total') -> ${price}`);
count(`$('#${price}')`, $(`#${price}`).length);
count(`document.querySelectorAll('#${price}')`, document.querySelectorAll(`#${price}`).length);
count("document.getElementById('price.total')", document.getElementById('price.total') ? 1 : 0);
const order = $.escapeSelector('order:42');
console.log(`$.escapeSelector('order:42') -> ${order}`);
count(`$('#${order}')`, $(`#${order}`).length);
// An id that starts with a digit is not a valid CSS identifier.
count("$('#1st-visit')", $('#1st-visit').length);
try {
document.querySelectorAll('#1st-visit');
} catch (error) {
console.log(`document.querySelectorAll('#1st-visit') throws ${error.name}`);
}
count("document.getElementById('1st-visit')", document.getElementById('1st-visit') ? 1 : 0);
console.log(`$.escapeSelector('1st-visit') -> ${$.escapeSelector('1st-visit')}`); // Prints what checkout/escape.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1 (recorded.mjs).
import { show } from './show.mjs';
show('checkout/escape.html'); Output
$('#price.total') -> 0 found
$.escapeSelector('price.total') -> price\.total
$('#price\.total') -> 1 found
document.querySelectorAll('#price\.total') -> 1 found
document.getElementById('price.total') -> 1 found
$.escapeSelector('order:42') -> order\:42
$('#order\:42') -> 1 found
$('#1st-visit') -> 1 found
document.querySelectorAll('#1st-visit') throws SyntaxError
document.getElementById('1st-visit') -> 1 found
$.escapeSelector('1st-visit') -> \31 st-visit
(jQuery 3.7.1 logged the same 11 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node escape.mjs
$('#price.total') found nothing. $.escapeSelector(), added in
jQuery 3.0, puts a backslash before every character with a special meaning in CSS, and the escaped selector found the
element both in jQuery and in querySelectorAll(). Browsers have the same function, CSS.escape(): jQuery’s
documentation calls $.escapeSelector() “essentially a shim” for it, and MDN’s example turns .foo#bar into
\.foo\#bar (CSS.escape()). jsdom has no CSS.escape(), so
the page shows jQuery’s.
An id that begins with a digit is not a valid name in CSS, so document.querySelectorAll('#1st-visit') threw a
SyntaxError. $('#1st-visit') found the element anyway, because a selector that is a lone id takes jQuery’s
shortcut through getElementById(), the first branch of the figure in the first section: the code works with jQuery
and breaks without it. Escaping writes the digit as its code point, \31 .
When the selector is only the id
Skip escaping and write document.getElementById('price.total'): it compares the id as plain text. Use
CSS.escape(id) when the id is part of a longer selector, such as `#${CSS.escape(id)} > img`.
Checkpoint
Check: Ids with special characters
Custom pseudo-selectors need createPseudo() in jQuery 4
Plugins and older application code define selectors of their own, such as :soldout, by adding a function to jQuery’s
selector engine. jQuery 4 keeps two of the ways legacy code does it and drops the third, without an error. The menu
items of the checkout page carry their stock in a data-stock attribute, and pseudos.js defines a
pseudo-class: The part of a selector that starts with a colon and tests a state or a position of an element, such as :checked or :first-child in CSS. jQuery adds pseudo-classes of its own and lets code define more on jQuery.expr.pseudos. each way:
// pseudos.js: custom pseudo-selectors, defined the three ways legacy code does it, and a standard rewrite.
const texts = (list) => [...list].map((li) => li.textContent).join(', ') || 'nothing';
// 1. A function on jQuery.expr.pseudos.
jQuery.expr.pseudos.soldout = (el) => el.dataset.stock === '0';
console.log(`li:soldout -> ${texts($('li:soldout'))}`);
// 2. The same, through the old name jQuery.expr[':'].
jQuery.expr[':'].instock = (el) => el.dataset.stock !== '0';
console.log(`li:instock -> ${texts($('li:instock'))}`);
// 3. An argument read from match[3], the way pseudos with an argument were once written.
jQuery.expr.pseudos.stock = function (el, i, match) {
return el.dataset.stock === match[3];
};
console.log(`li:stock(4) -> ${texts($('li:stock(4)'))}`);
// The supported way to take an argument: createPseudo().
jQuery.expr.pseudos.stockof = jQuery.expr.createPseudo((count) => (el) => el.dataset.stock === count);
console.log(`li:stockof(4) -> ${texts($('li:stockof(4)'))}`);
// No extension needed: a standard attribute selector.
console.log(`li[data-stock="4"] -> ${texts(document.querySelectorAll('li[data-stock="4"]'))}`); // Prints what checkout/pseudos.js logged in jsdom, with jQuery 4.0.0 and then 3.7.1, and what changed when the same
// page loaded jQuery Migrate 4.0.2 (recorded.mjs).
import { show, showAgainst } from './show.mjs';
show('checkout/pseudos.html');
console.log('With jQuery Migrate 4.0.2 on the page:');
showAgainst('checkout/pseudos-migrate.html', 'checkout/pseudos.html'); Output
li:soldout -> Thali, Dosa, Vada pav li:instock -> Biryani, Samosa li:stock(4) -> nothing li:stockof(4) -> Biryani li[data-stock="4"] -> Biryani (jQuery 3.7.1 logged other lines:) line 3: li:stock(4) -> Biryani With jQuery Migrate 4.0.2 on the page: JQMIGRATE: Migrate is installed with logging active, version 4.0.2 (info) JQMIGRATE: jQuery.expr[':'] is deprecated; use jQuery.expr.pseudos (info) JQMIGRATE: Pseudos with multiple arguments are deprecated; use jQuery.expr.createPseudo() li:stock(4) -> Biryani (and the same 4 lines as before)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pseudos.mjs
li:soldout and li:instock worked on both versions: a function on jQuery.expr.pseudos, or the same through its old
name jQuery.expr[':'], which jQuery Migrate reports as deprecated (warnings.md).
li:stock(4) is the silent break. Its function reads the argument from match[3], the way custom pseudo-selectors
with an argument were once written; jQuery 4 dropped this “legacy custom pseudos” form
(4.0 upgrade guide), so match[3] no longer holds the 4, the test is false
for every item, and the selector found nothing on 4.0.0 where 3.7.1 found Biryani. Migrate 4.0.2 brought the old
behaviour back and logged “Pseudos with multiple arguments are deprecated”.
The fix is jQuery.expr.createPseudo(), which receives the argument once and returns the test; li:stockof(4) found
Biryani on both versions. Often no extension is needed at all: the standard li[data-stock="4"] found the same item,
and it keeps working once jQuery is gone. These are the releases that shaped the selectors you will meet in old code:
- 1.0
Extensions from the start extensions
The first jQuery already matched selectors that CSS lacks, such as :even and :input, next to the standard ones.
- 1.5.2
:text without a type changed match
:text started to match input elements with no type attribute, whose type is text by default.
- 3.0
escapeSelector() and :visible new method
$.escapeSelector() arrived, and :visible started to count every element with a layout box, even one of zero width and height.
- 3.4
Positional selectors deprecated deprecated
:first, :last, :eq, :even, :odd, :lt, :gt and :nth were deprecated in favour of the methods .first(), .last() and .eq().
- 3.5
.even() and .odd() new method
The methods .even() and .odd() gave :even and :odd a method to move to.
- 3.7
Sizzle moves into jQuery engine
The selector engine Sizzle, until then a separate project, became part of jQuery's own code, to prepare for changes to selection in later versions.
- 4.0
Legacy custom pseudos dropped removed
Custom pseudo-selectors that read their argument from a match array stopped working; jQuery.expr.createPseudo() takes their place.
The timeline has 7 events, from 1.0 to 4.0.
Checkpoint
Check: Custom pseudo-selectors in jQuery 4
Practise: find the extensions in a codebase
Migrate is silent about positional selectors, so an audit before a migration searches the selector strings themselves. Write the function that does it for one string; the sample tests run in your browser:
Exercise · Medium · JavaScript
Find the jQuery-only selectors in a selector string
Before a page can move from $() to document.querySelectorAll(), every selector string in its code has to be free of jQuery's extensions, and jQuery Migrate does not report all of them. Write findExtensions(selector) in extensions.mjs: it returns the extensions one selector string uses, so that a script can list them for a whole codebase.
- Report each pseudo-class of this list, written with its colon and without any argument:
:animated,:button,:checkbox,:contains,:eq,:even,:file,:first,:gt,:header,:hidden,:image,:input,:last,:lt,:odd,:parent,:password,:radio,:reset,:selected,:submit,:textand:visible.:has()is standard CSS now and is not reported. - Report the attribute selector
[name!="value"], which CSS does not have either, as[!=]. - Report each extension once, in the order it first appears in the string.
- Standard pseudo-classes are not extensions, even when their names begin the same way:
:checked,:first-child,:last-of-typeand:nth-child(2)are CSS. - Text inside quotes and inside an attribute selector is not a selector:
a[href$=":first"]andp:contains("Total: 420")use no:firstand no:420, anda[title="x!=y"]is not[!=]. - A backslash escapes the character after it, as in the escaped ids of the lesson:
#step\:firstis the element whose id isstep:first, so it uses no:first.
For example, findExtensions('#checkout :checkbox:checked, li:first') returns [':checkbox', ':first'].
Starter code · extensions.mjs
/**
* The jQuery selector extensions a selector string uses, each once, in the order they first appear: ':checkbox',
* ':first', ':contains' (without its argument), or '[!=]' for an attribute-not-equal selector.
* @param {string} selector
* @returns {string[]}
*/
export function findExtensions(selector) {
return [];
} The sample tests · extensions.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { findExtensions } from './extensions.mjs';
test('standard selectors use no extension', () => {
assert.deepEqual(findExtensions('input[type=checkbox]:checked'), []);
assert.deepEqual(findExtensions('ul > li:nth-child(2), li:first-child, li:last-of-type'), []);
assert.deepEqual(findExtensions('ul:has(li.sale)'), []);
});
test('form and positional extensions are reported without their argument', () => {
assert.deepEqual(findExtensions('#checkout :checkbox:checked, li:first'), [':checkbox', ':first']);
assert.deepEqual(findExtensions('li:eq(2), tr:even'), [':eq', ':even']);
assert.deepEqual(findExtensions('li:not(:first):visible'), [':first', ':visible']);
});
test('each extension is reported once, in the order it first appears', () => {
assert.deepEqual(findExtensions(':text, form :text:visible, :input'), [':text', ':visible', ':input']);
});
test('text in quotes and in attribute selectors is not a selector', () => {
assert.deepEqual(findExtensions('p:contains("Total: 420")'), [':contains']);
assert.deepEqual(findExtensions('a[href$=":first"]'), []);
assert.deepEqual(findExtensions("a[title='Help: :hidden']:last"), [':last']);
assert.deepEqual(findExtensions('a[title="x!=y"]'), []);
});
test('an escaped character belongs to the name it is in', () => {
assert.deepEqual(findExtensions('#step\\:first, #order\\:42 :checkbox'), [':checkbox']);
assert.deepEqual(findExtensions('#price\\.total:visible'), [':visible']);
});
test('the attribute-not-equal selector is reported as [!=]', () => {
assert.deepEqual(findExtensions('input[name!="token"]'), ['[!=]']);
assert.deepEqual(findExtensions('li:odd, a[rel!="nofollow"], li:odd'), [':odd', '[!=]']);
}); A hint
Scan the string once, from left to right, with one regular expression that has an alternative for each kind of piece: an escaped character (a backslash and the character after it), a quoted string ("…" or '…'), an attribute selector ([…], which may hold a quoted value) and a pseudo-class (: followed by letters and hyphens). selector.matchAll(re) gives the pieces in order. Skip the escapes and the quoted strings, report [!=] for an attribute selector whose operator, right after the attribute's name, is !=, and report a pseudo-class only when its whole name is in a Set of the extensions: :first must not match the start of :first-child.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then practise every idea of the lesson: the questions begin where your answers are weakest, and each choice explains itself as soon as you pick it:
Practice that adapts to you
Practise this lesson
12 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 (fresher to 2–5 years): why can a selector work in $() and throw in document.querySelectorAll()?
jQuery first hands a selector to the browser’s querySelectorAll(). When the browser refuses it, because it uses a
jQuery extension such as :checkbox, :first or :contains(), jQuery catches the SyntaxError and matches the
selector with its own engine, so the page never sees an error. document.querySelectorAll() has no such fallback and
throws. A lone id is a second case: $('#1st-visit') goes through getElementById(), while querySelectorAll()
refuses #1st-visit because a CSS name cannot begin with a digit. Before removing jQuery, rewrite the extensions as
standard CSS or methods such as .first(), and escape or look up unusual ids.
The bank of this module holds two harder ones: clearing a whole codebase of extensions, and a custom selector that went quiet after an upgrade. Sketch your own answer first, then compare it with the first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your team will replace jQuery's selectors with document.querySelectorAll() across a large codebase. How do you find the jQuery-only selectors, translate them, and show that each translation selects the same elements as before?
-
Mid to senior
After an upgrade to jQuery 4, $('tr:status(paid)') returns no rows and the console shows no error. The :status pseudo-selector was written years ago. What is wrong, and how do you fix it so that it works on both 3.7 and 4.0?
Key takeaways
- jQuery tries every selector on
querySelectorAll()first and matches extensions with its own engine, so code that calls the DOM directly throws aSyntaxErrorfor each extension it still holds. - Most form extensions have standard rewrites, but
:textalso matches inputs without a type,:checkedalso matches selected options, and:contains()also matches every ancestor of the words. - Positional selectors such as
:first,:evenand:gt()count the whole set from 0 and are deprecated since 3.4;.first(),.even(),.eq()and.slice()do the same job, and Migrate does not report them. - Escape ids that hold dots, colons or a leading digit with
$.escapeSelector()orCSS.escape(), or look a lone id up withgetElementById(). - A custom pseudo-selector that reads
match[3]finds nothing in jQuery 4; write it withjQuery.expr.createPseudo(), or replace it with a standard attribute selector.
Terms in this lesson
- positional selector
- A jQuery selector extension that picks elements by their position in the whole matched set, counted from 0: :first, :last, :eq(), :even, :odd, :lt() and :gt(). They are deprecated since jQuery 3.4 in favour of methods such as .first() and .eq().
- pseudo-class
- The part of a selector that starts with a colon and tests a state or a position of an element, such as :checked or :first-child in CSS. jQuery adds pseudo-classes of its own and lets code define more on jQuery.expr.pseudos.
- selector extension
- A selector that jQuery understands but CSS does not, such as :checkbox, :first or :contains(). jQuery matches it with its own engine; the browser's querySelectorAll() throws a SyntaxError for it.
References
- Selectors: jQuery Extensions (OpenJS Foundation (jQuery API documentation))
- jQuery 3.7.0 Released: Staying in Order (OpenJS Foundation (jQuery blog))
- :text Selector (OpenJS Foundation (jQuery API documentation))
- :input Selector (OpenJS Foundation (jQuery API documentation))
- :contains() Selector (OpenJS Foundation (jQuery API documentation))
- :visible Selector (OpenJS Foundation (jQuery API documentation))
- jQuery source: src/css/hiddenVisibleSelectors.js (4.0.0) (OpenJS Foundation (jQuery))
- Element: checkVisibility() method (MDN Web Docs (Mozilla))
- jQuery 3.4.0 Released (OpenJS Foundation (jQuery blog))
- :first Selector (OpenJS Foundation (jQuery API documentation))
- :even Selector (OpenJS Foundation (jQuery API documentation))
- :eq() Selector (OpenJS Foundation (jQuery API documentation))
- jQuery.escapeSelector() (OpenJS Foundation (jQuery API documentation))
- CSS: escape() static method (MDN Web Docs (Mozilla))
- :has() (MDN Web Docs (Mozilla))
- DOM Standard: querySelectorAll() (WHATWG)
- jQuery Core 4.0 Upgrade Guide: legacy custom pseudos (OpenJS Foundation (jQuery))
- jQuery Migrate: warnings and their meaning (OpenJS Foundation (jQuery))
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress