webapi: give every indexer implementation a query handler

If an explicit query handler isn't provided, we bridge creates a generic one
that wraps the getter.
This commit is contained in:
Karl Seguin committed 2026-09-23 14:52:33 +08:00
1 parent 31198445dd
commit 3262e78aec
6 files changed
+182 -15

No files matched your search

+3 -2
View File
@@ -275,10 +275,11 @@ fn createSnapshotContext(
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
const window_index = @import("../webapi/Window.zig").JsApi.index;
v8.v8__ObjectTemplate__SetIndexedHandler(global_template, &.{
.getter = @import("../webapi/Window.zig").JsApi.index.getter,
.getter = window_index.getter,
.setter = null,
.query = null,
.query = window_index.query,
.deleter = null,
.enumerator = null,
.definer = null,
+46 -12
View File
@@ -398,20 +398,25 @@ pub const Indexed = struct {
}.wrap;
}
if (@typeInfo(@TypeOf(query)) != .null) {
indexed.query = struct {
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return js.Intercepted.no;
}
defer caller.deinit();
const query_func = if (@typeInfo(@TypeOf(query)) != .null)
query
else
// Generate a Query handler by wrapping getter. With no setter, this
// gets the ReadOnly attribute
GetterQuery(getter, if (@typeInfo(@TypeOf(setter)) == .null) v8.ReadOnly else v8.None).query;
return caller.getIndexQuery(T, query, idx, handle.?);
indexed.query = struct {
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return js.Intercepted.no;
}
}.wrap;
}
defer caller.deinit();
return caller.getIndexQuery(T, query_func, idx, handle.?);
}
}.wrap;
if (@typeInfo(@TypeOf(definer)) != .null) {
indexed.definer = struct {
@@ -433,6 +438,35 @@ pub const Indexed = struct {
}
};
// Default index query if one isn't provided. Uses the getter to determine the result
fn GetterQuery(comptime getter: anytype, comptime attrs: u32) type {
const params = @typeInfo(@TypeOf(getter)).@"fn".params;
const Self = params[0].type.?;
const Index = params[1].type.?;
return struct {
const query = if (params.len == 3) withGlobal else plain;
fn plain(self: Self, idx: Index) !u32 {
return attributes(getter(self, idx));
}
fn withGlobal(self: Self, idx: Index, global: params[2].type.?) !u32 {
return attributes(getter(self, idx, global));
}
fn attributes(ret: anytype) !u32 {
const value = switch (@typeInfo(@TypeOf(ret))) {
.error_union => try ret,
else => ret,
};
if (@typeInfo(@TypeOf(value)) == .optional and value == null) {
return error.NotHandled;
}
return attrs;
}
};
}
pub const NamedIndexed = struct {
getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32,
setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<form id=f><input name=i1><select id=s><option>a</option><option>b</option></select></form>
<div id=d class="x y" data-a=1></div>
<iframe></iframe>
<script id=supported_indices>
{
// Web IDL: a supported index is enumerable, configurable and, unless
// there's an indexed setter (HTMLOptionsCollection), read-only. An
// unsupported one isn't there at all.
const d = document.getElementById('d');
const cases = {
HTMLCollection: document.getElementsByTagName('div'),
NodeList: document.querySelectorAll('div'),
NamedNodeMap: d.attributes,
HTMLOptionsCollection: document.getElementById('s').options,
HTMLAllCollection: document.all,
HTMLFormControlsCollection: document.getElementById('f').elements,
DOMTokenList: d.classList,
Window: window,
};
for (const [name, obj] of Object.entries(cases)) {
const desc = Object.getOwnPropertyDescriptor(obj, 0);
const writable = name === 'HTMLOptionsCollection';
testing.expectEqual([name, true, true, writable].join(' '), [name, desc.enumerable, desc.configurable, desc.writable].join(' '));
testing.expectEqual(name + ' true', name + ' ' + obj.propertyIsEnumerable(0));
testing.expectEqual(name + ' true', name + ' ' + (0 in obj));
testing.expectEqual(name + ' false', name + ' ' + (9999 in obj));
testing.expectEqual(name + ' true', name + ' ' + (Object.getOwnPropertyDescriptor(obj, 9999) === undefined));
}
}
</script>
<script id=enumeration>
{
// the query must not filter supported indices out of enumeration
const list = document.querySelectorAll('div');
testing.expectEqual(['0'], Object.keys(list));
const seen = [];
for (const k in list) seen.push(k);
testing.expectEqual('0', seen[0]);
}
</script>
<script id=read_only>
(function() {
'use strict';
const list = document.querySelectorAll('div');
const first = list[0];
testing.expectError('TypeError', () => { list[0] = 'x'; });
testing.expectEqual(first, list[0]);
})();
</script>
@@ -275,6 +275,44 @@
}
</script>
<script id="options_indexed_setter">
{
const sel = document.createElement('select')
const opts = sel.options
const make = (v) => { const o = document.createElement('option'); o.value = v; return o }
// past the end pads with blank options, then appends
opts[2] = make('c')
testing.expectEqual(3, opts.length)
testing.expectEqual(['', '', 'c'], Array.from(opts, (o) => o.value))
// in range replaces in place, including inside an optgroup (Firefox;
// Chrome removes it and add()s the new one before the next option)
opts[0] = make('a')
testing.expectEqual(['a', '', 'c'], Array.from(opts, (o) => o.value))
const group = document.createElement('optgroup')
group.appendChild(opts[1])
sel.appendChild(group)
opts[2] = make('b')
testing.expectEqual(group, opts[2].parentNode)
testing.expectEqual(['a', 'c', 'b'], Array.from(opts, (o) => o.value))
// null removes; null past the end is a no-op
opts[1] = null
testing.expectEqual(['a', 'b'], Array.from(opts, (o) => o.value))
opts[10] = null
testing.expectEqual(2, opts.length)
// only options (or null) are accepted
testing.expectError('TypeError', () => { opts[0] = document.createElement('div') })
testing.expectEqual(2, opts.length)
const desc = Object.getOwnPropertyDescriptor(opts, 0)
testing.expectEqual(true, desc.writable)
testing.expectEqual(true, desc.enumerable)
}
</script>
<script id="select_add">
{
const sel = document.createElement('select')
@@ -347,3 +347,8 @@ pub const JsApi = struct {
pub const namedItem = bridge.function(HTMLCollection.getByName, .{});
pub const symbol_iterator = bridge.iterator(HTMLCollection.iterator, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: indexed properties" {
try testing.htmlRunner("collections/indexed_properties.html", .{});
}
@@ -104,6 +104,41 @@ pub fn remove(self: *HTMLOptionsCollection, index: i32, frame: *Frame) void {
}
}
// Chrome's cap (kMaxListItems): padding up to a huge index would otherwise
// create that many options.
const max_list_items = 100_000;
// The indexed setter: null removes the option at index; an index past the
// end pads with blank options and then appends; otherwise the option at
// index is replaced.
fn setAtIndex(self: *HTMLOptionsCollection, index: u32, option_: ?*Option, frame: *Frame) !void {
const existing = self.getAtIndex(index, frame);
const option = (option_ orelse {
if (existing) |element| {
element.remove(frame);
}
return;
}).asElement().asNode();
if (existing) |element| {
const old = element.asNode();
_ = try old.parentNode().?.replaceChild(option, old, frame);
return;
}
if (index >= max_list_items) {
return;
}
const select_node = self._select.asNode();
const doc = select_node.ownerDocument(frame).?;
for (self.length(frame)..index) |_| {
const blank = try doc.createElementNS("http://www.w3.org/1999/xhtml", "option", frame);
_ = try select_node.appendChild(blank.asNode(), frame);
}
_ = try select_node.appendChild(option, frame);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(HTMLOptionsCollection);
@@ -117,7 +152,7 @@ pub const JsApi = struct {
pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{});
// Indexed access
pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[int]" = bridge.indexedReadWrite(HTMLOptionsCollection.getAtIndex, setAtIndex, null, null, null, .{ .null_as_undefined = true, .ce_reactions = true });
pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, null, struct {
fn wrap(self: *HTMLOptionsCollection, name: []const u8, frame: *Frame) !u32 {
if (self.getByName(name, frame) != null) {