Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

jQuery 4 (legacy code and migration to DOM APIs)  Module 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.

  • Intermediate
  • 15 minutes
  • Examples run with Node.js 24.21.0, jQuery 4.0.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step Where append, prepend, before and after put an element Step through the four insertions into the cart and watch where each new element lands, inside the list or beside it.
Where append, prepend, before and after put an element, step 6 of 6. Showing h2: Your cart, ul#cart-a, li: Tea, li: Rice, li: Dal, p: Delivery in 2 days, .before(), .prepend(), .append(), .after(). In focus: h2: Your cart, ul#cart-a, li: Tea, li: Rice, li: Dal, p: Delivery in 2 days.h2: Your cartbeside the listul#cart-ali: Teafirst childli: Ricethere from the startli: Dallast childp: Delivery in 2 daysbeside the list.before().prepend().append().after()

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

Every step as text
  1. The cart. The list holds one item, Rice. Every call below is made on the list itself, $('#cart-a').
  2. append() adds a last child. .append('<li>Dal</li>') puts the new item inside the list, after its last child.
  3. prepend() adds a first child. .prepend('<li>Tea</li>') puts the new item inside the list too, before its first child.
  4. before() adds a sibling above. .before('<h2>Your cart</h2>') puts the heading outside the list, right before it, as its previous sibling.
  5. after() adds a sibling below. .after('<p>Delivery in 2 days</p>') puts the paragraph outside the list, right after it.
  6. 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().

The cart page and insert.js

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(', ')}`);
What insert.js logged JavaScript · insert.mjs
// 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

  1. Question 1 of 2 $('#cart-a').before('<h2>Your cart</h2>') runs on the list of the cart. Where does the heading go?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Outside the list, right before it, as its previous sibling

    append() and prepend() work inside the element; before() and after() work beside it. The DOM's methods of the same names do the same, as the second cart showed.

  2. Question 2 of 2 Which line adds Ghee as the last item of the cart, as $('#cart-a').append('<li>Ghee</li>') would?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: $('<li>Ghee</li>').appendTo('#cart-a')

    Each insertion method has a reversed twin: appendTo(), prependTo(), insertBefore() and insertAfter(). The run ended with Salt, Tea, Rice, Dal, Ghee after appendTo() and insertBefore().

.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, on the review page JavaScript · shop/scripts.js
// 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 &amp; 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'})`);
What scripts.js logged JavaScript · scripts.mjs
// 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 &amp; 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:

Interactive · stage by stage What happens to an HTML string in .html() and .append() Step through the review string on its way into the page, and see where innerHTML and .text() part ways with jQuery.
What happens to an HTML string in .html() and .append(), step 6 of 6. Showing An HTML string, Parsed into elements, Inserted into the page (.text(): the tags as text), jQuery runs the scripts (nothing to run). In focus: text, Inserted into the page..text(): one text nodeAn HTML string<b>Great</b> tea<script>…Parsed into elementsa b and a script elementInserted into the page.text(): the tags as textjQuery runs the scriptsnothing to run

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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().
  5. innerHTML stops earlier. innerHTML and insertAdjacentHTML() parse and insert the same elements, but nothing runs the script afterwards: the run logged no for both.
  6. .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 &amp; 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

  1. Question 1 of 2 The same review string, with a bold word and a script element, went into the page five ways. In which cases did its script run?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • .html()
    • .append()

    jQuery's manipulation methods run the scripts of the HTML they insert, which the DOM's own HTML setters never do. Treat every string passed to them as code, and pass user text to .text().

  2. Question 2 of 2 A comment reads Tom &amp; Jerry <b>loved</b> it, exactly as the customer typed it. What does $('<p>').append(comment) show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Tom & Jerry loved it, with loved in bold

    The DOM's append() turns a string into a text node, while jQuery's append() reads a string as HTML as soon as it holds < or an entity. Code that moves from one to the other changes meaning both ways.

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, on the toolbar page JavaScript · shop/selfclose.js
// 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>'))}`);
What selfclose.js logged on three versions, then with Migrate's patch JavaScript · selfclose.mjs
// 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

  1. Question 1 of 2 A toolbar sets $('#save').html('<i class="icon-save"/> Save'). Where does the word Save end up on jQuery 3.7.1 and 4.0.0?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Inside the i element, after the icon

    Write the closing tag: '<i class="icon-save"></i> Save'. Then every version builds the same structure, and the label stays out of the icon element.

  2. Question 2 of 2 How can jQuery Migrate 4.0.2 help you find the strings that the 3.5 change affects?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Calling jQuery.migrateEnablePatches('self-closed-tags') in development makes it warn about each affected string

    The patch brings the old, unsafe parsing back while it is on, so it belongs in development only, until every reported string has explicit closing tags.

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, on the cart page JavaScript · shop/cleanup.js
// 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))}`);
What cleanup.js logged JavaScript · cleanup.mjs
// 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:

Interactive · step by step What a widget leaves behind when its element is removed Step through the two widgets, then the two ways of removing their elements, and watch what stays in the page.
What a widget leaves behind when its element is removed, step 6 of 6. Showing input#city (removed by the DOM), div#help (removed by the DOM), ul.ui-autocomplete (still in the body), div.ui-dialog (still in the body, empty), div.ui-widget-overlay (still covers the page), document handlers (click 1, focusin 1). In focus: ul.ui-autocomplete, div.ui-dialog, div.ui-widget-overlay, document handlers.input#cityremoved by the DOMdiv#helpremoved by the DOMul.ui-autocompletestill in the bodydiv.ui-dialogstill in the body, emptydiv.ui-widget-overlaystill covers the pagedocument handlersclick 1, focusin 1

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. A second copy. The second delivery form builds the same widgets again: three nodes in the body and the two document handlers.
  5. 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.
  6. 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, on the delivery page JavaScript · shop/widgets.js
// 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()}`);
What widgets.js logged JavaScript · widgets.mjs
// 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

  1. Question 1 of 2 Which method takes an element out of the page and keeps its jQuery data and event handlers for when you put it back?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: .detach()

    detach() is remove() without the cleanup, meant for elements that will come back, such as a row moved to another table.

  2. Question 2 of 2 A page removes a jQuery UI autocomplete input and a modal dialog's element with the DOM's own remove(). What did the run find afterwards?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 3 widget nodes and the document's click and focusin handlers still there

    jQuery UI destroys a widget when jQuery's cleanup of its element runs, which remove(), empty() and html() do. The DOM's remove() skips that, so remove widget elements with jQuery, or call the widget's destroy() first.

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

The review list and native.js

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}`);
What native.js logged JavaScript · native.mjs
// 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 &lt;b&gt;any&lt;/b&gt; other &amp; 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

  1. Question 1 of 2 Which native line puts a customer's text into an element without parsing it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: el.textContent = text

    textContent, the DOM's append(string) and replaceChildren(string) all make text nodes from strings. Only the HTML setters and jQuery's HTML-taking methods parse.

  2. Question 2 of 2 native.js rendered two reviews from a template whose li holds a b and a span. One review text holds markup. How many elements did the list hold afterwards?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 6 elements

    Each review is an li with a b and a span: 3 elements, so 6 for two. The markup in the second review stayed text, so it added no element, as the run counted.

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 &amp;, 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
  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 &lt;b&gt;any&lt;/b&gt; 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 &amp; Jerry loved it' });
  assert.deepEqual(page.parsed, []);
  assert.equal(page.html(), '<ul><li class="comment"><b>Meera</b> <span class="text">Tom &amp;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>&lt;i&gt;Kabir&lt;/i&gt;</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.

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.

HTML Entity Encoder & Decoder Encode and decode entities to see what a comment looks like once a parser has read it. HTML, CSS & JS Playground (Online Editor) Run the review page in a real browser and try each insertion method with your own strings.

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.

  1. Question 1 of 11 What does .prepend(content) do?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: It inserts the content inside the element, before its first child

    prepend() and append() add children at either end; before() and after() add siblings at either side.

  2. Question 2 of 11 $('.cart').append($badge) runs on a page with three carts, and $badge is one element. What happens?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: Each cart gets the badge; the last one gets the original and the others clones

    The DOM's append() moves a node; jQuery inserts clones into all targets but the last. Each clone repeats the badge's id, so give a repeated element a class rather than an id.

  3. Question 3 of 11 Which jQuery call puts a customer's review into an element as plain text?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: $el.text(review)

    jQuery's documentation warns that any method that accepts an HTML string can potentially execute code. For text, use .text(), or build the element with a fixed tag and fill it with .text().

  4. Question 4 of 11 A list holds three reviews. What does $('#reviews li').html() return?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: The HTML inside the first review only

    To read every review, loop over the set, for example with .toArray().map((li) => li.innerHTML).

  5. Question 5 of 11 A developer argues that innerHTML is safe for user text because it does not run script elements. What is wrong with that?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: Parsed markup can run code without a script element, through event handler attributes

    The run showed that the script stayed inert with innerHTML, but parsing still turned the user's markup into elements. Text goes in with textContent or .text(); markup from users needs a sanitiser.

  6. Question 6 of 11 Which string builds the same element on jQuery 3.4.1, 3.7.1 and 4.0.0?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: $('<div class="box"/>')

    The 3.5 upgrade guide notes that a single tag, with or without attributes, still works, because the parser closes it at the end of the input. Strings with more than one tag are the ones to check.

  7. Question 7 of 11 Why did jQuery 3.5 stop rewriting <div/> into <div></div>?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: The rewriting could turn sanitised HTML into code that runs, the flaw recorded as CVE-2020-11022

    jQuery.htmlPrefilter() now returns its argument unchanged. Restoring the old rewrite brings the flaw back, so fix the strings instead.

  8. Question 8 of 11 What does .clone(true) copy that .clone() does not?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: jQuery's event handlers and data

    clone(true) is jQuery's way to copy behaviour along with structure. The DOM's cloneNode(true) copies structure only, like clone().

  9. Question 9 of 11 The Dal row was removed with the DOM's remove() and put back. What did the run find?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Its jQuery data and its click handler were still there

    The DOM's remove() keeps whatever jQuery stored on the element. That is harmless for an element that comes back, but it is also why widgets are never destroyed when their elements are removed that way.

  10. Question 10 of 11 What does list.replaceChildren(...items) do?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: It removes every child of the list and inserts the given nodes, in one step

    replaceChildren() is the native counterpart of $list.empty().append(…), without any HTML parsing.

  11. Question 11 of 11 Why use a <template> element for the review rows instead of building each row from an HTML string?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: The structure is written once in HTML, and only text goes into each copy

    The HTML standard defines the template's content as an inert fragment for code to clone. Clone, fill with text, insert: that pattern replaces string building safely.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • append() and prepend() insert inside an element, before() and after() 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() and textContent insert text, and jQuery’s append() 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’s remove() leaves everything jQuery attached, widget parts included.
  • Native code parses no text with textContent, append(), replaceChildren() and a template element, and keeps innerHTML and insertAdjacentHTML() 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.