selector: attribute names match case-sensitively on foreign elements

Fixes WPT /dom/nodes/querySelector-mixed-case.html: per the Selectors
spec, attribute names in selectors match ASCII case-insensitively
against HTML elements (in an HTML document) but case-sensitively
against foreign (SVG/MathML) elements. We lowercased the selector's
attribute name at parse time for everyone, so [viewBox] never matched
the case-preserved viewBox attribute on an SVG element.

The parsed attribute selector now also keeps the name as written;
matching picks the lowercased name for HTML elements (whose stored
attributes are normalized) and the original for foreign elements
(whose attributes are stored as written).

Coverage: /dom/nodes/querySelector-mixed-case.html 0/1 -> 1/1 (fully
green); Element-matches.html stays 669/669.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-12 11:59:55 +02:00
committed by Karl Seguin
parent 40c18f6142
commit 9ff221e2c0
3 changed files with 13 additions and 3 deletions

View File

@@ -445,7 +445,11 @@ fn matchesPart(el: *Node.Element, part: Part, scope: *Node, frame: *Frame) bool
}
fn matchesAttribute(el: *Node.Element, attr: Selector.Attribute) bool {
const value = el.getAttributeSafe(attr.name) orelse {
// Attribute names match ASCII case-insensitively on HTML elements (both
// sides lowercased) and case-sensitively on foreign elements, whose
// attributes are stored as written.
const name = if (el._namespace == .html) attr.name else attr.original_name;
const value = el.getAttributeSafe(name) orelse {
return false;
};

View File

@@ -962,6 +962,8 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute {
}
const attr_name = try self.attributeName(arena);
// As written, for the case-sensitive match on foreign elements.
const original_name = try arena.dupe(u8, attr_name);
// Normalize the name to lowercase for fast matching (consistent with Attribute.normalizeNameForLookup)
const name = try Attribute.normalizeNameForLookupAlloc(arena, .wrap(attr_name));
@@ -974,7 +976,7 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute {
if (self.peek() == ']') {
self.input = self.input[1..];
}
return .{ .name = name, .matcher = .presence, .case_insensitive = case_insensitive };
return .{ .name = name, .original_name = .wrap(original_name), .matcher = .presence, .case_insensitive = case_insensitive };
}
const matcher_type = try self.attributeMatcher();
@@ -1012,7 +1014,7 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute {
.presence => unreachable,
};
return .{ .name = name, .matcher = matcher, .case_insensitive = case_insensitive };
return .{ .name = name, .original_name = .wrap(original_name), .matcher = matcher, .case_insensitive = case_insensitive };
}
fn attributeName(self: *Parser, arena: Allocator) ![]const u8 {

View File

@@ -229,7 +229,11 @@ pub const Part = union(enum) {
};
pub const Attribute = struct {
// Lowercased for the HTML-element case-insensitive match.
name: String,
// As written in the selector: attribute names match case-sensitively on
// foreign (SVG/MathML) elements, whose attributes are stored unnormalized.
original_name: String,
matcher: AttributeMatcher,
case_insensitive: bool,
};