Skip to content

Commit 1c73c3f

Browse files
silverwindclaude
andauthored
fix: Properly decode CSS escape sequences in attribute selector values (#2080)
* fix: Properly decode CSS escape sequences in attribute selector values CSS.escape() produces hex escape sequences (e.g. \30 for "0") that querySelectorAll failed to match because the parser only stripped backslashes instead of decoding them per the CSS Syntax Level 3 spec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix ref --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7fa06b3 commit 1c73c3f

File tree

2 files changed

+66
-4
lines changed

2 files changed

+66
-4
lines changed

packages/happy-dom/src/query-selector/SelectorParser.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ const SELECTOR_PSEUDO_REGEXP = /:([a-zA-Z-]+)|([()])/gm;
5757
*/
5858
const ESCAPED_CHARACTER_REGEXP = /\\/g;
5959

60+
/**
61+
* CSS Escape RegExp.
62+
*
63+
* Matches CSS escape sequences: hex escapes (e.g. \30 , \0041) and character escapes (e.g. \:, \\).
64+
* Hex escapes consist of 1-6 hex digits optionally followed by a single whitespace character.
65+
*/
66+
const CSS_UNESCAPE_REGEXP = /\\([0-9a-fA-F]{1,6})\s?|\\(.)/g;
67+
6068
/**
6169
* Attribute Escape RegExp.
6270
*/
@@ -349,28 +357,30 @@ export default class SelectorParser {
349357
// Matches attributes with apostrophes, e.g. [attr='value'] or [attr="value"] or [attr='value' i]
350358

351359
const value = match[13] ?? match[14];
360+
const unescapedValue = SelectorParser.cssUnescape(value);
352361
selectorItem.attributes = selectorItem.attributes || [];
353362
selectorItem.attributes.push({
354363
name: match[9].replace(ESCAPED_CHARACTER_REGEXP, ''),
355364
operator: match[11] || null,
356-
value: value.replace(ESCAPED_CHARACTER_REGEXP, ''),
365+
value: unescapedValue,
357366
modifier: <'s'>match[15] || null,
358367
regExp: this.getAttributeRegExp({
359368
operator: match[11],
360-
value,
369+
value: unescapedValue,
361370
modifier: match[15]
362371
})
363372
});
364373
} else if (match[16] && match[19] !== undefined) {
365374
// Matches attributes without apostrophes, e.g. [attr=value] or [attr=value i]
366375

376+
const unescapedValue = SelectorParser.cssUnescape(match[19]);
367377
selectorItem.attributes = selectorItem.attributes || [];
368378
selectorItem.attributes.push({
369379
name: match[16].replace(ESCAPED_CHARACTER_REGEXP, ''),
370380
operator: match[18] || null,
371-
value: match[19].replace(ESCAPED_CHARACTER_REGEXP, ''),
381+
value: unescapedValue,
372382
modifier: null,
373-
regExp: this.getAttributeRegExp({ operator: match[18], value: match[19] })
383+
regExp: this.getAttributeRegExp({ operator: match[18], value: unescapedValue })
374384
});
375385
} else if (match[21]) {
376386
// Matches pseudo selectors with arguments, e.g. ":nth-child(2n+1)" or ":not(.class)"
@@ -635,4 +645,22 @@ export default class SelectorParser {
635645

636646
return (n) => n > partB - 1;
637647
}
648+
649+
/**
650+
* Unescapes CSS escape sequences in a string value.
651+
*
652+
* Handles hex escapes (e.g. "\30 " → "0", "\0041" → "A") and character escapes (e.g. "\:" → ":").
653+
*
654+
* @see https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point
655+
* @param value Escaped CSS value.
656+
* @returns Unescaped value.
657+
*/
658+
private static cssUnescape(value: string): string {
659+
return value.replace(CSS_UNESCAPE_REGEXP, (_match, hex, char) => {
660+
if (hex) {
661+
return String.fromCodePoint(parseInt(hex, 16));
662+
}
663+
return char;
664+
});
665+
}
638666
}

packages/happy-dom/test/query-selector/QuerySelector.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,40 @@ describe('QuerySelector', () => {
649649
expect(elements[1] === container.children[0].children[1].children[1]).toBe(true);
650650
});
651651

652+
it('Returns elements when attribute value contains CSS hex escape sequences.', () => {
653+
const container = document.createElement('div');
654+
container.innerHTML =
655+
'<div class="toast" data-key="0abc"></div><div class="toast" data-key="other"></div>';
656+
657+
// CSS.escape('0abc') produces '\\30 abc' (hex escape for "0" followed by "abc")
658+
const elements = container.querySelectorAll('[data-key="\\30 abc"]');
659+
660+
expect(elements.length).toBe(1);
661+
expect(elements[0] === container.children[0]).toBe(true);
662+
});
663+
664+
it('Returns elements when attribute value contains CSS character escape sequences.', () => {
665+
const container = document.createElement('div');
666+
container.innerHTML = '<div data-key="a:b"></div>';
667+
668+
// Backslash-escaped colon
669+
const elements = container.querySelectorAll('[data-key="a\\:b"]');
670+
671+
expect(elements.length).toBe(1);
672+
expect(elements[0] === container.children[0]).toBe(true);
673+
});
674+
675+
it('Returns elements when using CSS.escape() with class selector and attribute value.', () => {
676+
const container = document.createElement('div');
677+
container.innerHTML = '<div class="toast" data-key="0abc"></div>';
678+
679+
const escaped = window.CSS.escape('0abc');
680+
const elements = container.querySelectorAll(`.toast[data-key="${escaped}"]`);
681+
682+
expect(elements.length).toBe(1);
683+
expect(elements[0] === container.children[0]).toBe(true);
684+
});
685+
652686
it('Returns all elements with an attribute value containing a specified word using "[class~="class2"]".', () => {
653687
const container = document.createElement('div');
654688
container.innerHTML = QuerySelectorHTML;

0 commit comments

Comments
 (0)