-
-
Notifications
You must be signed in to change notification settings - Fork 294
querySelector throws SyntaxError on selectors containing CSS hex escapes (e.g. #\39 7e...) #2088
Description
Describe the bug
querySelector and querySelectorAll throw a SyntaxError (or silently return null) when the selector contains CSS hex escape sequences produced by CSS.escape().
When an element ID starts with a digit (e.g. a UUID like 97e356d3-...), CSS.escape() correctly produces a hex escape: \39 7e356d3-.... Per CSS Syntax 3 §4.3.7, the \39 is 1-6 hex digits representing code point U+0039 ('9'), and the trailing space is a delimiter consumed by the tokenizer — it is not a descendant combinator.
happy-dom's SelectorParser uses SELECTOR_GROUP_REGEXP to split selectors at combinators, but this regex is not escape-aware. It sees the space inside \39 and incorrectly treats it as a descendant combinator, splitting #\39 7e356d3-... into two fragments: #\39 and 7e356d3-....
Additionally, ESCAPED_CHARACTER_REGEXP (/\\/g) only strips backslashes rather than decoding CSS hex escapes (\39 → 9), so even selectors that don't throw will never match.
The SyntaxError throw was introduced in v20.6.3 (commit e6a64da, PR #2068). The underlying hex escape parsing bug existed before that, but manifested as a silent null return rather than a hard error.
To Reproduce
const id = '97e356d3-601d-42ca-9c13-3446228274ac';
const container = document.createElement('div');
const child = document.createElement('span');
child.id = id;
container.appendChild(child);
// CSS.escape() produces: \39 7e356d3-...
container.querySelector(`#${CSS.escape(id)}`);
// Throws: SyntaxError: Failed to execute 'querySelectorAll' on 'Element':
// '#\39 7e356d3-...' is not a valid selector.A reproduction test is available at: https://stackblitz.com/edit/vitejs-vite-5gnp3mau?file=src%2Fcss-escape.test.ts
Expected behavior
querySelector(#${CSS.escape(id)})should not throw — the selector is valid CSS.- It should find and return the element — a spec-compliant parser decodes
\39as'9', producing the ID97e356d3-....
Both behaviors work correctly in Chrome, Firefox, and Safari.
Device:
- OS: Any (Node.js / Bun)
- Browser: happy-dom
- Version: >=20.6.3 throws
SyntaxError; <20.6.3 silently returnsnull(doesn't throw but doesn't find the element either)
Additional context
Spec references:
- CSS Syntax 3 §2.1 "Escaping" —
\+ 1-6 hex digits + optional whitespace - CSS Syntax 3 §4.3.7 "Consume an escaped code point" — normative algorithm for decoding hex escapes
- CSSOM §2.1 "Common Serializing Idioms" — defines "escape as code point" as
\+ hex + SPACE - CSSOM §8.1 "The CSS.escape() Method" — uses the above for leading digits
There are two things to fix:
SELECTOR_GROUP_REGEXP(or its replacement) must be escape-aware: when encountering\followed by 1-6 hex digits and an optional space, that space must not be treated as a combinator.ESCAPED_CHARACTER_REGEXPmust be replaced with a proper CSS hex escape decoder that converts\XXtoString.fromCodePoint(parseInt('XX', 16))per the spec, rather than just stripping backslashes.
Workaround: Use attribute selectors instead of CSS.escape():
container.querySelector(`[id="${id}"]`);