Escaping XML in Omnibox Descriptions

Fix broken and blank omnibox suggestions caused by unescaped text: the XML markup chrome.omnibox descriptions accept, escaping titles and URLs, highlighting matches safely with match, dim and url, and Firefox's plain-text descriptions.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The omnibox suggestions work for weeks, then one user reports that searching for “Q&A” shows nothing, and another sees “Unterminated entity reference” in the service worker console. The extension builds suggestion descriptions from saved page titles, and a title containing &, < or > turned the description into invalid XML. Chrome parses omnibox descriptions as XML so that you can style parts of them, which means every piece of untrusted text — titles, URLs, the user’s own query — must be escaped. This guide shows the markup, the escaping, and a safe way to highlight matched text. It belongs to omnibox and address bar integration.

Why descriptions are XML

chrome.omnibox suggestion descriptions and the default suggestion description are parsed as a small XML dialect with three style elements: <match> (highlighted, for the text that matched the query), <dim> (de-emphasised, for secondary info), and <url> (styled as a URL). Chrome wraps the description in a root element and parses it; if parsing fails, the suggestion is dropped or shown blank, and an error is logged. Characters with XML meaning — &, <, >, and inside attributes " and ' — must be written as entities. Since page titles and URLs routinely contain & (query strings) and sometimes <, any description built from data needs escaping. Firefox treats descriptions as plain text: markup is not interpreted.

How an unescaped title breaks a suggestionA saved title Q&A: Tips <2026> is inserted into a description without escaping; the XML parser fails at the ampersand; the suggestion is dropped. With escaping, the entities parse correctly and the title displays as typed.Title: Q&A: Tips <2026>untrusted dataUnescaped template`<match>…</match>`XML parse errorsuggestion droppedwith escapeXml()Q&amp;A: Tips &lt;2026&gt;entitiesValid XMLparsesShows: Q&A: Tips <2026>as typed
Escape every value before it meets markup.

Step-by-step: safe descriptions

1. Write one escaping function

1// xml.js
2const XML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" };
3export const escapeXml = (s) => String(s).replace(/[&<>"']/g, (c) => XML_ESCAPES[c]);

Execution context: the service worker. Escape & first by using a single replacement pass (as here) — chained replace calls that escape < before & double-escape. Escaping quotes too is harmless in text content and makes the function safe to reuse elsewhere.

2. Escape every interpolated value

1function describe(item) {
2  return `${escapeXml(item.title)} <dim>— ${escapeXml(item.folder)}</dim> <url>${escapeXml(item.url)}</url>`;
3}

Execution context: the service worker. Markup is yours and stays unescaped; every value from data is escaped. URLs need escaping too: ?a=1&b=2 contains &. Treat the user’s own query the same way when you echo it in the default suggestion.

Characters to escape in descriptionsCharacters that must be escaped in omnibox descriptions, their entities, where they commonly appear in extension data, and what happens if they are left raw.CharacterEntityCommon sourceIf raw&&amp;URLs, titles (Q&A)Parse error<&lt;Code titles, mathParse error or tag>&gt;Breadcrumbs (a > b)Usually OK, escape anyway" and '&quot; &apos;TitlesOK in text
Ampersands in URLs are the most common culprit.

3. Highlight matches without breaking escaping

1export function highlight(text, query) {
2  if (!query) return escapeXml(text);
3  const i = text.toLowerCase().indexOf(query.toLowerCase());
4  if (i < 0) return escapeXml(text);
5  return escapeXml(text.slice(0, i))
6    + "<match>" + escapeXml(text.slice(i, i + query.length)) + "</match>"
7    + escapeXml(text.slice(i + query.length));
8}
9// highlight("Q&A: React tips", "react") → "Q&amp;A: <match>React</match> tips"

Execution context: the service worker. Split the raw text around the match first, then escape each part separately, then insert markup. Escaping the whole string first and then searching for the query would fail when the query itself contains & (it would look for & inside &amp;), and inserting tags before escaping would escape your own tags.

Order of operations for a highlighted descriptionDecision tree showing the correct order: find the match in raw text, split, escape each part, then wrap the matched part in match tags; escaping first or tagging first both produce wrong output.When do you escape?before finding the matchWrongquery "&" misses "&amp;"after adding tagsWrongyour <match> becomes &lt;match&gt;per piece, then tagCorrectsplit raw → escape → wrap
Find in raw text, then escape pieces, then add tags.

4. Use the default suggestion safely too

1chrome.omnibox.onInputChanged.addListener((text, suggest) => {
2  chrome.omnibox.setDefaultSuggestion({
3    description: `Search saved pages for <match>${escapeXml(text)}</match>`,
4  });
5  suggest(results(text).map((it) => ({ content: it.url, description: `${highlight(it.title, text)} <url>${escapeXml(it.url)}</url>` })));
6});

Execution context: the service worker. The user’s own input is untrusted too — typing < into the omnibox should not break the default row. content is not parsed as XML and must not be escaped; it is returned verbatim to onInputEntered.

5. Catch failures in development

 1function assertValidDescription(desc) {
 2  if (!globalThis.DEBUG) return;
 3  try {
 4    // DOMParser is not available in service workers; use a tiny check instead
 5    const unescaped = desc.replace(/<\/?(match|dim|url)>/g, "");
 6    if (/[<>]|&(?!(amp|lt|gt|quot|apos);)/.test(unescaped)) throw new Error("unescaped character");
 7  } catch (e) {
 8    console.warn("Bad omnibox description:", desc, e);
 9  }
10}

Execution context: the service worker in development builds. Service workers have no DOMParser, so a regex check that strips the three allowed tags and looks for raw <, > or bare & catches nearly all mistakes. Run it over descriptions in tests with fixture titles containing every special character.

6. Strip markup for Firefox

1const isFirefox = typeof browser !== "undefined" && browser.runtime.getURL("").startsWith("moz-extension:");
2export const fmt = (desc) => isFirefox ? unescapeXml(desc.replace(/<\/?(match|dim|url)>/g, "")) : desc;

Execution context: the background in a cross-browser build. Firefox displays descriptions as plain text, so tags would appear literally and entities would show as &amp;. Strip tags and unescape for Firefox; keep the escaped XML for Chrome. See omnibox support and alternatives across browsers.

7. Keep descriptions short

Long descriptions are truncated. Put the most distinctive text first — usually the title with highlights — then dimmed context, then the URL. Trim titles to about 80 characters before escaping, so you never cut an entity in half.

8. Test with hostile fixtures

1const FIXTURES = ["Q&A", "a < b", "<script>", "Tom & Jerry's \"Best\"", "https://x.test/?a=1&b=2", "&amp; already"];
2for (const t of FIXTURES) expect(() => assertParses(highlight(t, "a"))).not.toThrow();

Execution context: a unit test. Include strings that already contain entity-like text (&amp;), which must be escaped again so the user sees exactly what was saved.

9. Truncate before escaping

1export function truncate(text, max = 80) {
2  if (text.length <= max) return text;
3  const cut = text.slice(0, max - 1);
4  return cut.replace(/\s+\S*$/, "") + "…";   // break at a word boundary
5}
6const description = `${highlight(truncate(item.title), query)} <url>${escapeXml(truncate(item.url, 60))}</url>`;

Execution context: the service worker. Truncating the raw text first, then escaping, guarantees you never cut through the middle of an entity such as &amp;, which would produce invalid XML just as surely as not escaping at all. Breaking at a word boundary and adding an ellipsis also reads better in the narrow dropdown. If the match falls after the cut, consider centring the truncated window on the match so the highlighted part stays visible.

Common mistakes

  • Escaping nothing. The first & breaks a suggestion.
  • Escaping after adding tags. Your markup shows literally.
  • Escaping content. It is not XML; the escaped text comes back on Enter.
  • Chained replaces in the wrong order. Double escaping.
  • Sending markup to Firefox. Tags appear as text.

Cross-browser variation

  • Chrome / Edge: XML descriptions with match, dim, url; invalid XML drops the suggestion.
  • Firefox: plain-text descriptions; strip tags and do not escape.
  • Safari: no omnibox API.

Verification

  1. Save pages titled “Q&A” and “a < b” and confirm suggestions display correctly.
  2. Type & and < as queries and confirm the default suggestion renders.
  3. Confirm no “Unterminated entity” errors appear in the worker console.
  4. In Firefox, confirm no tags or entities are visible.

FAQ

Are other tags allowed?

No. Only match, dim and url; anything else is an error or ignored.

Can I nest the tags?

Nesting is allowed in Chrome, but keep it simple; deeply nested markup is hard to read in the narrow dropdown.

Does escaping affect what Enter returns?

No. onInputEntered receives content or the raw typed text, never the description.

Can I use numeric entities like &#38;?

Yes, numeric character references are valid XML, but the five named entities are clearer. Do not use HTML-only named entities such as &nbsp; or &mdash; — they are not defined in XML and break parsing. Use the literal characters instead.

Do I need to escape text in setDefaultSuggestion?

Yes. The default suggestion’s description is parsed the same way, and it often includes the user’s query, which can contain any character.

Does escaping change the width of the text?

No. Entities render as single characters, so truncation limits apply to the raw text, not the escaped form.

Other UI/UX Patterns & Interactive Components Resources