webapi: supported HTMLCollection properties can't be deleted

Fixes 2 failing tests in WPT /dom/collections/HTMLCollection-delete.html
("Strict id" and "Strict name"): deleting a supported indexed or named
property of an HTMLCollection must fail (and throw a TypeError in strict
mode), since those properties are backed by the live collection.

HTMLCollection registered no deleter interceptors, so `delete c[0]` and
`delete c.name` fell through to the ordinary [[Delete]] which succeeded
on the (non-own) property.

HTMLCollection now registers an indexed and a named deleter (via the
bridge's indexedReadWrite/namedIndexed deleter callbacks) that return
false for supported properties - which v8 turns into a TypeError in
strict mode - and error.NotHandled for unsupported ones, leaving those
to the ordinary path.

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

Coverage: /dom/collections/HTMLCollection-delete.html 2/4 -> 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-10 17:09:42 +02:00
parent cc81c5993b
commit 01a16fe66e
2 changed files with 86 additions and 57 deletions

View File

@@ -209,59 +209,6 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn deleteIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _deleteIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
@@ -288,6 +235,59 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn deleteIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _deleteIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
@@ -312,7 +312,7 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
@@ -338,7 +338,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
@@ -453,6 +453,18 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value:
return js.Intercepted.yes;
}
// Setter/deleter interceptors normally return void: intercepting is enough
// to mark the operation successful. When they return a bool instead, it is
// forwarded as the v8 return value; false marks the operation as failed,
// which makes v8 throw a TypeError in strict mode.
fn returnsBool(comptime F: type) bool {
const RT = @typeInfo(F).@"fn".return_type.?;
return switch (@typeInfo(RT)) {
.error_union => |eu| eu.payload == bool,
else => RT == bool,
};
}
fn isInErrorSet(err: anyerror, comptime T: type) bool {
inline for (@typeInfo(T).error_set.?) |e| {
if (err == @field(anyerror, e.name)) return true;

View File

@@ -146,7 +146,7 @@ pub const JsApi = struct {
};
pub const length = bridge.accessor(HTMLCollection.length, null, .{});
pub const @"[int]" = bridge.indexed(HTMLCollection.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[int]" = bridge.indexedReadWrite(HTMLCollection.getAtIndex, null, deleteAtIndex, null, null, .{ .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 +155,24 @@ pub const JsApi = struct {
return self.getByName(name, frame) orelse error.NotHandled;
}
}.wrap, null, null, null, null, .{ .null_as_undefined = true });
}.wrap, null, deleteByName, null, null, .{ .null_as_undefined = true });
// Supported indexed and named properties can't be deleted (delete returns
// false, which throws a TypeError in strict mode); unsupported ones follow
// the ordinary [[Delete]] path.
fn deleteAtIndex(self: *HTMLCollection, idx: u32, frame: *Frame) !bool {
if (idx < self.length(frame)) {
return false;
}
return error.NotHandled;
}
fn deleteByName(self: *HTMLCollection, name: []const u8, frame: *Frame) !bool {
if (self.getByName(name, frame) != null) {
return false;
}
return error.NotHandled;
}
pub const item = bridge.function(_item, .{});
fn _item(self: *HTMLCollection, index: i32, frame: *Frame) ?*Element {