webapi: enumerate collection supported properties for ownPropertyNames

Fixes the Object.getOwnPropertyNames related failures in WPT
/dom/collections:

- HTMLCollection-live-mutations.window.html: "ownPropertyNames" case
- HTMLCollection-supported-property-names.html: 3 getOwnPropertyNames cases
- namednodemap-supported-property-names.html: all 3 cases
- domstringmap-supported-property-names.html: all 5 cases

Object.getOwnPropertyNames returned no interceptor-backed properties
because these collections registered no enumerator callbacks.

Using the bridge's existing indexed and named enumerator support:

- HTMLCollection registers an indexed enumerator (the supported
  indices) and a named enumerator implementing the spec's supported
  property names: for each element in tree order, its non-empty id and,
  for HTML elements, its non-empty name attribute, without duplicates.
- NamedNodeMap registers indexed and named enumerators (attribute
  qualified names in order, via List.getNames).
- DOMStringMap registers a named enumerator (camel-cased data-*
  attribute names). The previously unused kebabToCamel helper is fixed
  to follow the spec conversion: only a '-' followed by an ASCII
  lowercase letter is folded to an uppercase letter, a bare "data-"
  attribute maps to the empty name, and a trailing '-' is preserved.

(As originally written, this commit also added named enumerator
interceptor support to the js bridge; main gained equivalent support
independently, so the rebase keeps main's bridge API.)

Coverage:
- /dom/collections/HTMLCollection-live-mutations.window.html 4/5 -> 5/5
- /dom/collections/HTMLCollection-supported-property-names.html 2/6 -> 5/6
- /dom/collections/namednodemap-supported-property-names.html 0/3 -> 3/3
- /dom/collections/domstringmap-supported-property-names.html 0/5 -> 5/5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-10 17:19:19 +02:00
parent 01a16fe66e
commit 809b36d96a
3 changed files with 106 additions and 16 deletions

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Element = @import("../Element.zig");
@@ -146,7 +147,7 @@ pub const JsApi = struct {
};
pub const length = bridge.accessor(HTMLCollection.length, null, .{});
pub const @"[int]" = bridge.indexedReadWrite(HTMLCollection.getAtIndex, null, deleteAtIndex, null, null, .{ .null_as_undefined = true });
pub const @"[int]" = bridge.indexedReadWrite(HTMLCollection.getAtIndex, null, deleteAtIndex, null, getIndexes, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(struct {
pub fn wrap(self: *HTMLCollection, name: []const u8, frame: *Frame) !?*Element {
if (name.len == 0) {
@@ -155,7 +156,63 @@ pub const JsApi = struct {
return self.getByName(name, frame) orelse error.NotHandled;
}
}.wrap, null, deleteByName, null, null, .{ .null_as_undefined = true });
}.wrap, null, deleteByName, getNames, null, .{ .null_as_undefined = true });
fn getIndexes(self: *HTMLCollection, exec: *const Execution) !js.Array {
const frame = switch (exec.js.global) {
.frame => |f| f,
.worker => unreachable,
};
const len = self.length(frame);
var arr = exec.js.local.?.newArray(len);
for (0..len) |i| {
_ = try arr.set(@intCast(i), i, .{});
}
return arr;
}
// The supported property names: for each element represented by the
// collection, in tree order, its id and (for HTML elements) its name
// attribute, skipping empty values and duplicates.
fn getNames(self: *HTMLCollection, exec: *const Execution) !js.Array {
const frame = switch (exec.js.global) {
.frame => |f| f,
.worker => unreachable,
};
var names: std.ArrayList([]const u8) = .{};
const arena = exec.call_arena;
const len = self.length(frame);
for (0..len) |i| {
const element = self.getAtIndex(i, frame) orelse break;
if (element.getAttributeSafe(comptime .wrap("id"))) |id| {
if (id.len > 0 and !contains(names.items, id)) {
try names.append(arena, id);
}
}
if (element._namespace == .html) {
if (element.getAttributeSafe(comptime .wrap("name"))) |name| {
if (name.len > 0 and !contains(names.items, name)) {
try names.append(arena, name);
}
}
}
}
var arr = exec.js.local.?.newArray(@intCast(names.items.len));
for (names.items, 0..) |name, i| {
_ = try arr.set(@intCast(i), name, .{});
}
return arr;
}
fn contains(names: []const []const u8, name: []const u8) bool {
for (names) |n| {
if (std.mem.eql(u8, n, name)) return true;
}
return false;
}
// Supported indexed and named properties can't be deleted (delete returns
// false, which throws a TypeError in strict mode); unsupported ones follow

View File

@@ -620,8 +620,26 @@ pub const NamedNodeMap = struct {
};
pub const length = bridge.accessor(NamedNodeMap.length, null, .{});
pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, null, null, .{ .null_as_undefined = true });
pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, getIndexes, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, getNames, null, .{ .null_as_undefined = true });
fn getIndexes(self: *const NamedNodeMap, exec: *const js.Execution) !js.Array {
const len = self.length();
var arr = exec.js.local.?.newArray(len);
for (0..len) |i| {
_ = try arr.set(@intCast(i), i, .{});
}
return arr;
}
fn getNames(self: *const NamedNodeMap, exec: *const js.Execution) !js.Array {
const names = try self.list().getNames(exec.call_arena);
var arr = exec.js.local.?.newArray(@intCast(names.len));
for (names, 0..) |name, i| {
_ = try arr.set(@intCast(i), name, .{});
}
return arr;
}
pub const getNamedItem = bridge.function(NamedNodeMap.getByName, .{});
pub const setNamedItem = bridge.function(NamedNodeMap.set, .{ .ce_reactions = true });
pub const removeNamedItem = bridge.function(NamedNodeMap.removeByName, .{ .ce_reactions = true });

View File

@@ -98,27 +98,26 @@ fn camelToKebab(arena: Allocator, camel: String) !String {
return try String.init(arena, result.items, .{});
}
// data-foo-bar -> fooBar
// data-foo-bar -> fooBar. Returns null for non data-* attributes. Per spec,
// only a '-' followed by an ASCII lowercase letter is folded into an
// uppercase letter; any other '-' (e.g. trailing) is kept as-is, and the
// bare "data-" attribute maps to the empty name.
fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 {
if (!std.mem.startsWith(u8, kebab, "data-")) {
return null;
}
const data_part = kebab[5..]; // Skip "data-"
if (data_part.len == 0) {
return null;
}
var result: std.ArrayList(u8) = .empty;
try result.ensureTotalCapacity(arena, data_part.len);
var capitalize_next = false;
for (data_part) |c| {
if (c == '-') {
capitalize_next = true;
} else if (capitalize_next) {
result.appendAssumeCapacity(std.ascii.toUpper(c));
capitalize_next = false;
var i: usize = 0;
while (i < data_part.len) : (i += 1) {
const c = data_part[i];
if (c == '-' and i + 1 < data_part.len and std.ascii.isLower(data_part[i + 1])) {
result.appendAssumeCapacity(std.ascii.toUpper(data_part[i + 1]));
i += 1;
} else {
result.appendAssumeCapacity(c);
}
@@ -136,5 +135,21 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const @"[]" = bridge.namedIndexed(getProperty, setProperty, deleteProperty, null, null, .{ .null_as_undefined = true, .ce_reactions = true });
pub const @"[]" = bridge.namedIndexed(getProperty, setProperty, deleteProperty, getNames, null, .{ .null_as_undefined = true, .ce_reactions = true });
// The supported property names are the camel-cased names of the
// element's data-* attributes, in attribute order.
fn getNames(self: *DOMStringMap, exec: *const js.Execution) !js.Array {
var names: std.ArrayList([]const u8) = .empty;
for (try self._element._attributes.getNames(exec.call_arena)) |attr_name| {
const camel = (try kebabToCamel(exec.call_arena, attr_name)) orelse continue;
try names.append(exec.call_arena, camel);
}
var arr = exec.js.local.?.newArray(@intCast(names.items.len));
for (names.items, 0..) |name, i| {
_ = try arr.set(@intCast(i), name, .{});
}
return arr;
}
};