jQuery 4 (legacy code and migration to DOM APIs) Module 2 – Selecting, reading and changing the page
Changing content: html(), text(), append, remove
Insert, move, clone and remove elements with jQuery, see which methods run the scripts in HTML strings, fix self-closed tags and rewrite it all natively.
What you will learn
- Predict where append(), prepend(), before() and after() put content, and write the same insertions with DOM methods
- Choose between .text() and .html() for user text, and predict which methods run the scripts of inserted HTML
- Explain the jQuery 3.5 change to self-closed tags and fix the strings that relied on the old behaviour
- Remove, detach and clone elements without losing jQuery data and handlers or leaving widgets behind
- Rewrite manipulation code with textContent, append(), replaceChildren() and a template element
Before you start
What you’ll learn · Before you start
What you will learn
- Predict where append(), prepend(), before() and after() put content, and write the same insertions with DOM methods
- Choose between .text() and .html() for user text, and predict which methods run the scripts of inserted HTML
- Explain the jQuery 3.5 change to self-closed tags and fix the strings that relied on the old behaviour
- Remove, detach and clone elements without losing jQuery data and handlers or leaving widgets behind
- Rewrite manipulation code with textContent, append(), replaceChildren() and a template element
Before you start
On this page
One review, <b>Great</b> tea followed by a script element, went into a product page five ways: .html() and
.append() ran its script, innerHTML and insertAdjacentHTML() did not, and .text() showed the tags as typed.
Changing content is most of what jQuery code does, and the method decides whether a string becomes text, markup or
code that runs.
The pages of this lesson are parts of a small shop, run in jsdom on jQuery 4.0.0 and again on 3.7.1, and on 3.4.1 where
a string’s meaning changed in 3.5. You will place content with the four insertion methods, keep customers’ words out
of the HTML parser, fix strings that relied on self-closed tags, remove elements without leaving data, handlers or
widgets behind, and write the same changes with textContent, append(), replaceChildren() and a template.
Four insertion methods put content in four places
append() and prepend() add content inside an element, after its last child or before its first; before() and
after() add it beside the element, as a sibling. The cart page calls all four on a list that holds one item, and
does the same to a second copy of the list with the DOM’s methods of the same names:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The cart. The list holds one item, Rice. Every call below is made on the list itself, $('#cart-a').
- append() adds a last child. .append('<li>Dal</li>') puts the new item inside the list, after its last child.
- prepend() adds a first child. .prepend('<li>Tea</li>') puts the new item inside the list too, before its first child.
- before() adds a sibling above. .before('<h2>Your cart</h2>') puts the heading outside the list, right before it, as its previous sibling.
- after() adds a sibling below. .after('<p>Delivery in 2 days</p>') puts the paragraph outside the list, right after it.
- The DOM does the same. The DOM's own append(), prepend(), before() and after() built the same HTML on the second cart, from elements made with createElement().
shop/insert.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Your cart</title>
</head>
<body>
<div id="with-jquery"><ul id="cart-a"><li>Rice</li></ul></div>
<div id="with-dom"><ul id="cart-b"><li>Rice</li></ul></div>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="insert.js"></script>
</body>
</html> shop/insert.js
// insert.js: the same four insertions into two copies of a cart, with jQuery and with the DOM's own methods.
$('#cart-a').append('<li>Dal</li>');
$('#cart-a').prepend('<li>Tea</li>');
$('#cart-a').before('<h2>Your cart</h2>');
$('#cart-a').after('<p>Delivery in 2 days</p>');
const make = (tag, text) => {
const el = document.createElement(tag);
el.textContent = text;
return el;
};
const cart = document.getElementById('cart-b');
cart.append(make('li', 'Dal'));
cart.prepend(make('li', 'Tea'));
cart.before(make('h2', 'Your cart'));
cart.after(make('p', 'Delivery in 2 days'));
const a = document.getElementById('with-jquery').innerHTML;
const b = document.getElementById('with-dom').innerHTML.replace('cart-b', 'cart-a');
console.log(a);
console.log(`the DOM methods built the same HTML: ${a === b}`);
// The reversed forms take the target as their argument.
$('<li>Ghee</li>').appendTo('#cart-a');
$('<li>Salt</li>').insertBefore('#cart-a li:first-child');
console.log(`after appendTo() and insertBefore(): ${$('#cart-a li').toArray().map((li) => li.textContent).join(', ')}`); // Prints what shop/insert.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/insert.html'); Output
<h2>Your cart</h2><ul id="cart-a"><li>Tea</li><li>Rice</li><li>Dal</li></ul><p>Delivery in 2 days</p> the DOM methods built the same HTML: true after appendTo() and insertBefore(): Salt, Tea, Rice, Dal, Ghee (jQuery 3.7.1 logged the same 3 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node insert.mjs
Both carts came out the same: heading, list of Tea, Rice and Dal, delivery line. The DOM gained append(),
prepend(), before() and after() with the same meanings, and they take nodes or strings, a string always becoming
a text node (DOM Standard: ParentNode and
ChildNode). Each jQuery method also has a
reversed twin that takes the target as its argument: appendTo(), prependTo(), insertBefore() and
insertAfter(). The appendTo() and insertBefore() line of the output used two of them.
One difference has no DOM counterpart. The DOM moves a node to its new place; jQuery does too for one target, but when
the set holds several targets it inserts “cloned copies of the inserted element” into each one except the last, which
gets the original (.append()). So $('.cart').append($badge) on three carts puts a
badge in each, and an id on the badge would then appear three times on the page.
Checkpoint
Check: Where the insertion methods put content
.text() inserts text; .html() and .append() parse it and run its scripts
The review page puts the same string into five boxes, then checks each box for a bold element and for whether the string’s script ran. The script only notes its name in a list, so the page can report it:
// scripts.js: one review with markup and a harmless script, put into the page five ways. The script only notes
// that it ran.
window.ran = [];
const review = (way) => `<b>Great</b> tea<script>window.ran.push('${way}')</script>`;
$('#by-html').html(review('.html()'));
$('#by-append').append(review('.append()'));
document.getElementById('by-innerhtml').innerHTML = review('innerHTML');
document.getElementById('by-adjacent').insertAdjacentHTML('beforeend', review('insertAdjacentHTML()'));
$('#by-text').text(review('.text()'));
const ways = { 'by-html': '.html()', 'by-append': '.append()', 'by-innerhtml': 'innerHTML', 'by-adjacent': 'insertAdjacentHTML()', 'by-text': '.text()' };
for (const [id, way] of Object.entries(ways)) {
const box = document.getElementById(id);
const bold = box.querySelector('b') ? 'a <b> element' : 'no element';
console.log(`${way}: ${bold}, script ran: ${window.ran.includes(way) ? 'yes' : 'no'}`);
}
console.log(`.text() shows: ${document.getElementById('by-text').textContent}`);
// A plain string looks like text, but jQuery's append() parses it as HTML when it holds < or an entity.
const comment = 'Tom & Jerry <b>loved</b> it';
const $a = $('<p>').append(comment);
const p = document.createElement('p');
p.append(comment);
console.log(`$('<p>').append(comment) shows: ${$a.text()} (bold element: ${$a.find('b').length ? 'yes' : 'no'})`);
console.log(`p.append(comment) shows: ${p.textContent} (bold element: ${p.querySelector('b') ? 'yes' : 'no'})`); // Prints what shop/scripts.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/scripts.html'); Output
.html(): a <b> element, script ran: yes
.append(): a <b> element, script ran: yes
innerHTML: a <b> element, script ran: no
insertAdjacentHTML(): a <b> element, script ran: no
.text(): no element, script ran: no
.text() shows: <b>Great</b> tea<script>window.ran.push('.text()')</script>
$('<p>').append(comment) shows: Tom & Jerry loved it (bold element: yes)
p.append(comment) shows: Tom & Jerry <b>loved</b> it (bold element: no)
(jQuery 3.7.1 logged the same 8 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node scripts.mjs
Every method that parsed the string made the bold element, but only jQuery’s two ran the script. jQuery parses the string into detached elements, with the scripts kept from running as the HTML standard keeps them when markup is parsed this way, inserts the elements, and then runs each script that is now in the page. Step through the path of the string:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A string with markup. The review holds a bold word and a script element. In .html() and .append(), jQuery reads a string that holds markup as HTML.
- Parsed. jQuery turns the string into elements in a detached container, with the scripts kept from running, as the HTML standard keeps them when markup is parsed this way.
- Inserted. The elements go into the target element. The bold word is now an element of the page in every case that parsed the string.
- jQuery runs the scripts. Then jQuery takes each script element that is now in the page and runs its code once. The recorded run logged that the script ran for .html() and .append().
- innerHTML stops earlier. innerHTML and insertAdjacentHTML() parse and insert the same elements, but nothing runs the script afterwards: the run logged no for both.
- .text() parses nothing. .text() makes one text node from the whole string, so the page shows the tags as typed and no element appears.
jQuery’s documentation says it in one line: “any jQuery constructor or method that accepts an HTML string” can
potentially execute code, and strings from URL parameters, cookies or form inputs must never go in, because they
open the page to 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.
(.append()). The DOM’s innerHTML is not the safe alternative it may look like: it
leaves script elements inert, but other markup it creates can still run code, and MDN’s security note says to use
textContent for plain text (innerHTML).
The two append(comment) lines of the output show a trap in append(). A comment typed as
Tom & Jerry <b>loved</b> it passed to $('<p>').append(comment) came out as “Tom & Jerry loved it”, with a bold
element, because jQuery reads a string as HTML as soon as it holds < or an entity. The DOM’s p.append(comment) showed it as typed. The two methods
share a name and only one of them parses, so check every append() of a string when code moves from one to the other.
Customers' words go in as text
Put text that a user, a URL or a server response supplies into the page with .text() or textContent, never with
.html(), .append(string), $(string) or innerHTML. Build elements from fixed tags, such as
$('<li class="comment">'), and fill them with .text().
Checkpoint
Check: Which methods parse HTML and run scripts
Since jQuery 3.5, a self-closed tag stays open
In HTML, <span/> is not an empty span: the parser reads it as an opening tag and ignores the slash. Until 3.5.0,
jQuery.htmlPrefilter() rewrote such tags in every HTML string, <span/> to <span></span>; since 3.5.0 it returns
its argument unchanged (3.5 upgrade guide). The change is the fix for
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”: before 3.5.0, HTML from untrusted sources, even after sanitising, could run code
once passed to .html() or .append()
(advisory). The toolbar page builds four
strings, on 4.0.0, 3.7.1 and 3.4.1:
// selfclose.js: HTML strings with self-closed tags, as legacy code writes them.
const tops = ($set) => $set.toArray().map((el) => el.outerHTML).join(' + ');
console.log(`$('<div/><span/>') -> ${tops($('<div/><span/>'))}`);
$('#save').html('<i class="icon-save"/> Save');
console.log(`the button holds: ${$('#save').html()}`);
console.log(`$('<div class="box"/>') -> ${tops($('<div class="box"/>'))}`);
console.log(`$('<div></div><span></span>') -> ${tops($('<div></div><span></span>'))}`); // Prints what shop/selfclose.js logged in jsdom with jQuery 4.0.0, 3.7.1 and 3.4.1, then what jQuery Migrate 4.0.2
// reported before and after its self-closed-tags patch was switched on (recorded.mjs).
import { show } from './show.mjs';
show('shop/selfclose.html');
console.log('With jQuery Migrate 4.0.2, before and after its self-closed-tags patch is switched on:');
show('shop/find-self-closed.html'); Output
$('<div/><span/>') -> <div><span></span></div>
the button holds: <i class="icon-save"> Save</i>
$('<div class="box"/>') -> <div class="box"></div>
$('<div></div><span></span>') -> <div></div> + <span></span>
(jQuery 3.7.1 logged the same 4 lines)
(jQuery 3.4.1 logged other lines:)
line 1: $('<div/><span/>') -> <div></div> + <span></span>
line 2: the button holds: <i class="icon-save"></i> Save
With jQuery Migrate 4.0.2, before and after its self-closed-tags patch is switched on:
JQMIGRATE: Migrate is installed with logging active, version 4.0.2
patch off, the button holds: <i class="icon-save"> Save</i>
(warning) JQMIGRATE: HTML tags must be properly nested and closed: <i class="icon-save"/> Save
patch on, the button holds: <i class="icon-save"></i> Save
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node selfclose.mjs
$('<div/><span/>') gave two siblings on 3.4.1 and a div holding a span on 3.7.1 and 4.0.0. The second line is the
bug a maintainer meets: an icon written as <i class="icon-save"/> before its label now holds the label, so “Save”
is styled as part of the icon. A single tag, such as <div class="box"/>, built the same element on every
version, because the parser closes it at the end of the string, and strings with explicit closing tags never changed.
To find the affected strings in a large codebase, jQuery Migrate has a patch that is off by default: with Migrate
loaded, the same string still put the label inside the icon, and Migrate said nothing.
jQuery.migrateEnablePatches('self-closed-tags'), called in development, brings the old rewrite back and warns about
each string it rewrites, as the last lines of the output show (3.5 upgrade guide,
warnings.md). It restores the unsafe behaviour
while it is on, so it is a search tool and never a fix.
Restoring the old htmlPrefilter instead of fixing the strings
Wrong: redefining jQuery.htmlPrefilter to rewrite /> again, which brings the 3.5.0 flaw back. Right: close the
tags in each string, '<i class="icon-save"></i> Save', which builds the same thing on every version.
Checkpoint
Check: Self-closed tags since jQuery 3.5
jQuery’s remove() cleans up what jQuery attached; the DOM’s does not
jQuery keeps data and event handlers for the elements it touches, and .remove() deletes them along with the
elements: “all bound events and jQuery data associated with the elements are removed”
(.remove()). .detach() takes elements out and keeps both, for elements that will
come back; .empty() removes an element’s children. The cart page tries each on three items with a price and a click
handler, then copies a button three ways:
// cleanup.js: what detach(), remove() and the DOM's remove() keep of jQuery's data and event handlers.
const clicks = [];
$('.drop').on('click', function () {
clicks.push($(this).parent().attr('id'));
});
$('#tea').data('price', 120);
$('#rice').data('price', 90);
$('#dal').data('price', 110);
const report = (how, $item) => {
$item.appendTo('#saved');
$item.find('.drop').trigger('click');
const clicked = clicks.includes($item.attr('id')) ? 'still runs' : 'gone';
console.log(`${how}: data ${$item.data('price') ?? 'gone'}, click handler ${clicked}`);
};
report('detach() then re-insert', $('#tea').detach());
report('remove() then re-insert', $('#rice').remove());
const dal = document.getElementById('dal');
dal.remove();
report("the DOM's remove() then re-insert", $(dal));
// clone() copies elements; clone(true) also copies jQuery's handlers and data.
const $button = $('<button type="button">Buy</button>').on('click', () => clicks.push('buy')).data('sku', 'TEA-250');
const runs = (copy) => {
const before = clicks.length;
$(copy).trigger('click');
return clicks.length > before ? 'runs' : 'does not run';
};
console.log(`clone(): handler ${runs($button.clone())}, data ${$button.clone().data('sku') ?? 'none'}`);
console.log(`clone(true): handler ${runs($button.clone(true))}, data ${$button.clone(true).data('sku') ?? 'none'}`);
console.log(`cloneNode(true): handler ${runs($button[0].cloneNode(true))}`); // Prints what shop/cleanup.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/cleanup.html'); Output
detach() then re-insert: data 120, click handler still runs remove() then re-insert: data gone, click handler gone the DOM's remove() then re-insert: data 110, click handler still runs clone(): handler does not run, data none clone(true): handler runs, data TEA-250 cloneNode(true): handler does not run (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 cleanup.mjs
The DOM’s remove() behaved like detach(): jQuery never learned of it, so the data and the handler survived.
Copies differ in the same way. .clone(true) copies jQuery’s handlers and data, while .clone() and the DOM’s
cloneNode(true) copy only the elements (.clone()).
Keeping data is harmless for one row. For a jQuery UI widget: An interactive control that jQuery UI builds on an element, such as an autocomplete or a dialog. It may add elements elsewhere in the page and handlers on the document, and it removes them in its destroy() method, which jQuery's cleanup of its element calls. it is a leak,
because a widget builds parts outside its element and is told to destroy itself only through jQuery’s cleanup: jQuery UI hooks into that cleanup and calls the
widget’s destroy() when its element goes (widget.js).
The delivery page uses jQuery UI 1.14.2, a release tested against jQuery 4.0.0
(release notes). It makes an autocomplete and a modal
dialog twice, and removes their elements once each way:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The two elements. The delivery form has a city input and a help text. Nothing else is in the page yet: 0 widget nodes, no document handler.
- The widgets build more. autocomplete() adds a menu at the end of the body, and dialog() moves the help text into a dialog box with a modal overlay. They also listen to click and focusin on the whole document.
- jQuery's remove(). $('#city').remove() and $('#help').remove() run jQuery's cleanup on the two elements, which tells each widget to destroy itself. The run counted 0 widget nodes and no handler left.
- A second copy. The second delivery form builds the same widgets again: three nodes in the body and the two document handlers.
- The DOM's remove(). This time the DOM's own remove() takes out the two elements and nothing else. jQuery's cleanup never runs, so the widgets never learn that their elements are gone.
- What stays. The run counted 3 widget nodes and both document handlers still there: the suggestions menu, an empty dialog box, an overlay that covers the page, and handlers that run on every click for widgets that are gone.
// widgets.js: two jQuery UI widgets, an autocomplete and a modal dialog, removed with jQuery and with the DOM.
// jQuery._data() is jQuery's internal store: fine for a look while debugging, never for production code.
const leftovers = () => {
const nodes = $('body > .ui-autocomplete, body > .ui-dialog, body > .ui-widget-overlay').length;
const events = jQuery._data(document, 'events') || {};
const handlers = ['click', 'focusin'].map((type) => `${type} ${events[type] ? events[type].length : 0}`).join(', ');
return `${nodes} widget nodes in the page, document handlers: ${handlers}`;
};
console.log(`jQuery UI ${$.ui.version} on jQuery ${$.fn.jquery}`);
console.log(`before: ${leftovers()}`);
$('#city-a').autocomplete({ source: ['Pune', 'Patna', 'Puri'] });
$('#help-a').dialog({ modal: true });
console.log(`widgets made: ${leftovers()}`);
$('#city-a').remove();
$('#help-a').remove();
console.log(`after jQuery's remove(): ${leftovers()}`);
$('#city-b').autocomplete({ source: ['Pune', 'Patna', 'Puri'] });
$('#help-b').dialog({ modal: true });
console.log(`widgets made again: ${leftovers()}`);
document.getElementById('city-b').remove();
document.getElementById('help-b').remove();
console.log(`after the DOM's remove(): ${leftovers()}`); // Prints what shop/widgets.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/widgets.html'); Output
jQuery UI 1.14.2 on jQuery 4.0.0 before: 0 widget nodes in the page, document handlers: click 0, focusin 0 widgets made: 3 widget nodes in the page, document handlers: click 1, focusin 1 after jQuery's remove(): 0 widget nodes in the page, document handlers: click 0, focusin 0 widgets made again: 3 widget nodes in the page, document handlers: click 1, focusin 1 after the DOM's remove(): 3 widget nodes in the page, document handlers: click 1, focusin 1 (jQuery 3.7.1 logged other lines:) line 1: jQuery UI 1.14.2 on jQuery 3.7.1
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node widgets.mjs
After jQuery’s remove(), nothing was left. After the DOM’s, 3 widget nodes stayed, the suggestions menu, an empty
dialog box and an overlay that covers the page, and the document kept a click and a focusin handler for widgets that
no longer exist.
Key idea
Remove what jQuery or a plugin set up with jQuery’s own remove(), empty() or html(), or call the widget’s destroy() first: the DOM’s remove() takes out the element and leaves everything jQuery attached.
Checkpoint
Check: What remove(), detach() and clone() keep
Native code: textContent, append(), replaceChildren() and template
Once a page no longer needs jQuery, each of these changes has a native form that parses no text at all. The review list below renders the same kind of data without jQuery: the structure of a row is written once, in a template element: An HTML element whose content is neither shown nor run: an inert fragment that code copies with template.content.cloneNode(true), fills with textContent and inserts, a native way to repeat a structure., and only text goes into each copy. The template’s content is an inert fragment that code clones (HTML Standard):
shop/native.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Reviews</title>
</head>
<body>
<ul id="reviews"><li>Loading reviews</li></ul>
<template id="review">
<li class="review"><b class="author"></b> <span class="text"></span></li>
</template>
<script src="native.js"></script>
</body>
</html> shop/native.js
// native.js: reviews rendered without jQuery and without parsing any text: a <template> for the structure,
// textContent for the words, replaceChildren() to swap the list's content in one step.
const reviews = [
{ author: 'Asha', text: 'Strong and fresh.' },
{ author: 'Ravi', text: 'Better than <b>any</b> other & cheaper' },
];
const template = document.getElementById('review');
const items = reviews.map((review) => {
const item = template.content.firstElementChild.cloneNode(true);
item.querySelector('.author').textContent = review.author;
item.querySelector('.text').textContent = review.text;
return item;
});
document.getElementById('reviews').replaceChildren(...items);
console.log(document.getElementById('reviews').innerHTML.replaceAll('</li>', '</li>\n').trim());
console.log(`elements in the list: ${document.querySelectorAll('#reviews *').length}`); // Prints what shop/native.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('shop/native.html'); Output
<li class="review"><b class="author">Asha</b> <span class="text">Strong and fresh.</span></li> <li class="review"><b class="author">Ravi</b> <span class="text">Better than <b>any</b> other & cheaper</span></li> elements in the list: 6
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 second review’s markup arrived as text, escaped in the list’s HTML, and the list held exactly the 6 elements the
template gives two reviews. replaceChildren() swapped the loading line for the reviews in one call.
| jQuery | Native | The native form parses HTML |
|---|---|---|
$el.text(s) |
el.textContent = s |
No |
$el.append($node), $el.prepend(…) |
el.append(node), el.prepend(…) |
No |
$el.before(…), $el.after(…) |
el.before(…), el.after(…) |
No |
$el.empty().append(…) |
el.replaceChildren(…) |
No |
$el.html(markup) |
el.innerHTML = markup |
Yes: trusted markup only |
$el.append('<li>…</li>') |
el.insertAdjacentHTML('beforeend', …) |
Yes: trusted markup only |
$el.remove() |
el.remove() |
No, and no jQuery cleanup |
The last three rows need care. innerHTML and insertAdjacentHTML() parse their string, so keep them for markup you
wrote yourself, never for text that comes from users or servers. And el.remove() skips jQuery’s cleanup, so while a
page still runs widgets or handlers that jQuery set up, remove their elements with jQuery’s .remove().
Checkpoint
Check: Native code that parses no text
Practise: comments that stay text
The comment widget of the product page builds an HTML string from what customers type. Rebuild it from fixed tags and
.text(); the sample tests run in your browser and fail if any customer’s word reaches the HTML parser:
Exercise · Easy · JavaScript
Add comments to a list without parsing their words
A product page shows comments that customers typed, and addComment($, $list, comment) in comments.mjs adds one to the list. It builds an HTML string from the customer's words, so a comment with markup becomes markup on the page. Rewrite it so that no word a customer typed is ever parsed as HTML.
Each call adds one item at the end of $list, with exactly this structure, where the author and the text appear exactly as typed:
<li class="comment"><b>author</b> <span class="text">text</span></li>
- Make each element from a fixed tag with nothing of the customer's in it, such as
$('<li class="comment">'), and put the customer's words in with.text(). - Remember that
.append()and.html()read a string as HTML when it holds<or an entity such as&, so never pass them a customer's words. A string of your own without markup, such as' ', is fine. $is jQuery, passed in by the page.
The sample tests run without a browser, so they pass a small stand-in for jQuery: $(tag) with one fixed tag (and, as in jQuery, an optional object such as { class: 'comment', text: '…' }), and collections with .text(), .addClass(), .append() and .html(). The stand-in notes every string it has to parse as HTML, and a test fails if one of them held a customer's words.
Starter code · comments.mjs
/**
* Adds one comment to the end of a list: <li class="comment"><b>author</b> <span class="text">text</span></li>.
* @param {Function} $ jQuery
* @param {object} $list the list, a jQuery collection of one ul element
* @param {{ author: string, text: string }} comment what the customer typed
*/
export function addComment($, $list, comment) {
$list.append('<li class="comment"><b>' + comment.author + '</b> <span class="text">' + comment.text + '</span></li>');
} The sample tests · comments.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { addComment } from './comments.mjs';
// A small stand-in for jQuery. $('<li class="comment">') and the other one-tag strings make an element; a string
// with more markup, or a string that .append() or .html() receives holding < or an entity, is HTML that jQuery would
// parse: the stand-in notes it in `parsed` instead of parsing it. serialize() writes a list as HTML, escaping text.
function standIn() {
const parsed = [];
const ONE_TAG = /^<([a-z]+)(?: class="([\w -]*)")?\s*\/?>(?:<\/\1>)?$/;
const element = (tag, cls) => ({ tag, cls: cls ?? '', kids: [] });
const text = (value) => ({ text: String(value) });
const nodesOf = (item, html) => {
if (item && Array.isArray(item.nodes)) return item.nodes;
const s = String(item);
if (html || /<|&#?\w+;/.test(s)) {
parsed.push(s);
return [text('(parsed HTML)')];
}
return [text(s)];
};
const wrap = (nodes) => {
const set = { nodes, length: nodes.length };
nodes.forEach((node, i) => (set[i] = node));
set.text = (value) => {
for (const node of nodes) node.kids = [text(value)];
return set;
};
set.addClass = (name) => {
for (const node of nodes) node.cls = node.cls ? `${node.cls} ${name}` : name;
return set;
};
set.append = (...items) => {
for (const node of nodes) for (const item of items) node.kids.push(...nodesOf(item, false));
return set;
};
set.html = (value) => {
for (const node of nodes) node.kids = nodesOf(value, true);
return set;
};
return set;
};
const $ = (arg, props) => {
const m = typeof arg === 'string' ? ONE_TAG.exec(arg.trim()) : null;
if (m) {
const set = wrap([element(m[1], m[2])]);
if (props && props.class) set.addClass(props.class);
if (props && props.text !== undefined) set.text(props.text);
return set;
}
parsed.push(String(arg));
return wrap([element('parsed')]);
};
const escape = (s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
const serialize = (node) =>
'text' in node ? escape(node.text) : `<${node.tag}${node.cls ? ` class="${node.cls}"` : ''}>${node.kids.map(serialize).join('')}</${node.tag}>`;
const $list = wrap([element('ul')]);
return { $, $list, parsed, html: () => serialize($list[0]) };
}
test('a comment becomes one li.comment with the author in b and the text in span.text', () => {
const page = standIn();
addComment(page.$, page.$list, { author: 'Asha', text: 'Strong and fresh.' });
assert.equal(page.html(), '<ul><li class="comment"><b>Asha</b> <span class="text">Strong and fresh.</span></li></ul>');
});
test('markup in a comment stays text, and nothing of it is parsed', () => {
const page = standIn();
addComment(page.$, page.$list, { author: 'Ravi', text: 'Better than <b>any</b> other' });
assert.deepEqual(page.parsed, []);
assert.equal(page.html(), '<ul><li class="comment"><b>Ravi</b> <span class="text">Better than <b>any</b> other</span></li></ul>');
});
test('an entity in a comment appears exactly as it was typed', () => {
const page = standIn();
addComment(page.$, page.$list, { author: 'Meera', text: 'Tom & Jerry loved it' });
assert.deepEqual(page.parsed, []);
assert.equal(page.html(), '<ul><li class="comment"><b>Meera</b> <span class="text">Tom &amp; Jerry loved it</span></li></ul>');
});
test("markup in the author's name stays text", () => {
const page = standIn();
addComment(page.$, page.$list, { author: '<i>Kabir</i>', text: 'Good.' });
assert.deepEqual(page.parsed, []);
assert.equal(page.html(), '<ul><li class="comment"><b><i>Kabir</i></b> <span class="text">Good.</span></li></ul>');
});
test('comments are added in order, after the ones already there', () => {
const page = standIn();
addComment(page.$, page.$list, { author: 'Asha', text: 'First.' });
addComment(page.$, page.$list, { author: 'Ravi', text: 'Second.' });
assert.equal(
page.html(),
'<ul><li class="comment"><b>Asha</b> <span class="text">First.</span></li><li class="comment"><b>Ravi</b> <span class="text">Second.</span></li></ul>',
);
}); A hint
Build the item from three small pieces and put them together with .append(): $('<b>').text(comment.author), the string ' ', and $('<span class="text">').text(comment.text). .append() takes several arguments, in order. Then append the finished item to $list.
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.
With the widget fixed, answer the questions of this lesson; the next one always comes from the idea you know least so far, and every choice is explained:
Practice that adapts to you
Practise this lesson
11 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): how do .remove(), .detach() and .empty() differ?
.remove() takes the selected elements out of the page and deletes the jQuery data and event handlers of the elements
and everything inside them, so a removed element put back has neither. .detach() takes the elements out but keeps
their data and handlers, for elements you will insert again, such as a row moved to another table. .empty() keeps
the element and removes its children, with their data and handlers. .remove() and .empty() run jQuery’s cleanup,
which also destroys the jQuery UI widgets on what they remove; the DOM’s own el.remove() runs none, and leaves a
widget’s menu, dialog box and document handlers behind.
Two questions from the module’s bank go further, into a review list that a scan flagged and an icon label that moved after an upgrade. Plan your own answer, then open its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A security scan flags $('#reviews').append('<li>' + review.text + '</li>') on a product page. Explain the risk to the team, give the fix in jQuery and in plain DOM code, and say how you would find similar code in the project.
-
Mid to senior
After an upgrade from jQuery 3.4 to 3.7, the labels of several toolbar buttons appear inside their icons. The buttons are built with strings such as '<i class="icon-save"/> Save'. What changed, how do you find every affected string, and what is the fix?
Key takeaways
append()andprepend()insert inside an element,before()andafter()beside it; the DOM’s methods of the same names do the same, and jQuery clones the content for every target but the last..html(),.append()and the other methods that take an HTML string parse it and run its scripts;.text()andtextContentinsert text, and jQuery’sappend()even parses a plain string that holds<or an entity.- Since jQuery 3.5,
<i/>is an opening tag, so text after it ends up inside; close the tags, and use Migrate’s self-closed-tags patch only to find the strings. - jQuery’s
remove()deletes data and handlers and destroys widgets,detach()keeps them, and the DOM’sremove()leaves everything jQuery attached, widget parts included. - Native code parses no text with
textContent,append(),replaceChildren()and a template element, and keepsinnerHTMLandinsertAdjacentHTML()for markup you wrote.
Terms in this lesson
- 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.
- jQuery UI widget
- An interactive control that jQuery UI builds on an element, such as an autocomplete or a dialog. It may add elements elsewhere in the page and handlers on the document, and it removes them in its destroy() method, which jQuery's cleanup of its element calls.
- template element
- An HTML element whose content is neither shown nor run: an inert fragment that code copies with template.content.cloneNode(true), fills with textContent and inserts, a native way to repeat a structure.
References
- Manipulation: jQuery API documentation (OpenJS Foundation (jQuery API documentation))
- .append() (OpenJS Foundation (jQuery API documentation))
- .html() (OpenJS Foundation (jQuery API documentation))
- .remove() (OpenJS Foundation (jQuery API documentation))
- .clone() (OpenJS Foundation (jQuery API documentation))
- jQuery Core 3.5 Upgrade Guide: jQuery.htmlPrefilter changes (OpenJS Foundation (jQuery))
- Potential XSS vulnerability in jQuery.htmlPrefilter and related methods (CVE-2020-11022) (OpenJS Foundation (jQuery))
- jQuery Migrate: warnings and their meaning (OpenJS Foundation (jQuery))
- jQuery UI source: ui/widget.js (1.14.2) (OpenJS Foundation (jQuery UI))
- jQuery UI 1.14.2 released (OpenJS Foundation (jQuery UI blog))
- DOM Standard: the ParentNode mixin (append, prepend, replaceChildren) (WHATWG)
- DOM Standard: the ChildNode mixin (before, after, remove) (WHATWG)
- HTML Standard: the template element (WHATWG)
- HTML Standard: parsing HTML fragments (WHATWG)
- Element: innerHTML property, security considerations (MDN Web Docs (Mozilla))
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress