Merge pull request #3459 from lightpanda-io/named-interceptor-query

webapi: improve indexing / enumerable of various collections
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-10 06:45:37 +08:00
commit befea97d1e
13 files changed
+380 -49

No files matched your search

+26
View File
@@ -300,6 +300,8 @@ pub const Indexed = struct {
const Opts = struct {
as_typed_array: bool = false,
null_as_undefined: bool = false,
// Only applies to setter and deleter; getters don't mutate.
ce_reactions: bool = false,
};
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, query: anytype, definer: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
@@ -346,6 +348,18 @@ pub const Indexed = struct {
}
defer caller.deinit();
const ce_frame: ?*Frame = if (comptime opts.ce_reactions) switch (caller.local.ctx.global) {
.frame => |frame| frame,
.worker => null,
} else null;
var ce_checkpoint: usize = undefined;
if (comptime opts.ce_reactions) {
if (ce_frame) |frame| ce_checkpoint = frame._ce_reactions.push();
}
defer if (comptime opts.ce_reactions) {
if (ce_frame) |frame| frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
};
return caller.setIndex(T, setter, idx, c_value.?, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
@@ -364,6 +378,18 @@ pub const Indexed = struct {
}
defer caller.deinit();
const ce_frame: ?*Frame = if (comptime opts.ce_reactions) switch (caller.local.ctx.global) {
.frame => |frame| frame,
.worker => null,
} else null;
var ce_checkpoint: usize = undefined;
if (comptime opts.ce_reactions) {
if (ce_frame) |frame| ce_checkpoint = frame._ce_reactions.push();
}
defer if (comptime opts.ce_reactions) {
if (ce_frame) |frame| frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
};
return caller.deleteOrDefineIndex(T, deleter, idx, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
@@ -103,3 +103,25 @@
testing.expectEqual(false, !!document.all);
}
</script>
<script id=all_named_presence>
{
const all = document.all;
testing.expectEqual(true, 'a' in all);
testing.expectEqual(true, 'field' in all);
testing.expectEqual(true, 'anchor' in all);
testing.expectEqual(true, Object.hasOwn(all, 'b'));
testing.expectEqual(false, 'nonExistent' in all);
testing.expectEqual(false, Object.hasOwn(all, 'nonExistent'));
// name= only counts for the elements document.all exposes by name,
// so a named div is reachable by id but not by name
testing.expectEqual(false, 'alpha' in all);
// [LegacyUnenumerableNamedProperties]
testing.expectEqual(false, Object.keys(all).includes('a'));
testing.expectEqual(false, Object.getOwnPropertyDescriptor(all, 'a').enumerable);
}
</script>
+42
View File
@@ -2,6 +2,7 @@
<script src="../testing.js"></script>
<div id=attr1 ClasS="sHow"></div>
<div id=nnm class=box data-role=widget></div>
<script id=attributes>
const el1 = $('#attr1');
@@ -319,3 +320,44 @@
testing.expectEqual('s', el.getAttribute('slot'));
}
</script>
<script id=named_node_map_presence>
{
const attrs = $('#nnm').attributes;
testing.expectEqual(true, 'id' in attrs);
testing.expectEqual(true, 'data-role' in attrs);
testing.expectEqual(true, Object.hasOwn(attrs, 'class'));
testing.expectEqual(false, 'nonExistent' in attrs);
testing.expectEqual(false, Object.hasOwn(attrs, 'nonExistent'));
// presence tracks the attribute list
$('#nnm').setAttribute('title', 'hi');
testing.expectEqual(true, 'title' in attrs);
$('#nnm').removeAttribute('title');
testing.expectEqual(false, 'title' in attrs);
// inherited members are found by `in`, but are not own properties
testing.expectEqual(true, 'length' in attrs);
testing.expectEqual(false, Object.hasOwn(attrs, 'length'));
}
</script>
<script id=named_node_map_enumeration>
{
const attrs = $('#nnm').attributes;
// [LegacyUnenumerableNamedProperties]: only the indices enumerate...
testing.expectEqual('0,1,2', Object.keys(attrs).join(','));
// ...but the names are still own properties
const own = Object.getOwnPropertyNames(attrs);
testing.expectEqual(true, own.includes('id'));
testing.expectEqual(true, own.includes('class'));
testing.expectEqual(true, own.includes('data-role'));
testing.expectEqual(false, Object.getOwnPropertyDescriptor(attrs, 'id').enumerable);
testing.expectEqual('id', Object.getOwnPropertyDescriptor(attrs, 'id').value.name);
}
</script>
+98
View File
@@ -121,6 +121,104 @@
}
</script>
<script id=propertyEnumeration>
{
const el = document.getElementById('test');
// dataset is not [LegacyUnenumerableNamedProperties]: the names enumerate.
testing.expectEqual('foo,helloWorld', Object.keys(el.dataset).join(','));
testing.expectEqual('foo,helloWorld', Object.getOwnPropertyNames(el.dataset).join(','));
testing.expectEqual('{"foo":"bar","helloWorld":"test"}', JSON.stringify(el.dataset));
const desc = Object.getOwnPropertyDescriptor(el.dataset, 'foo');
testing.expectEqual('bar', desc.value);
testing.expectEqual(true, desc.enumerable);
testing.expectEqual(true, desc.configurable);
testing.expectEqual(true, desc.writable);
testing.expectEqual(undefined, Object.getOwnPropertyDescriptor(el.dataset, 'nonExistent'));
// inherited properties are still found by `in`, but are not own
testing.expectEqual(true, 'toString' in el.dataset);
testing.expectEqual(false, Object.hasOwn(el.dataset, 'toString'));
}
</script>
<script id=presenceTracksAttribute>
{
const el = document.createElement('div');
const ds = el.dataset;
testing.expectEqual(false, 'live' in ds);
el.setAttribute('data-live', 'x');
testing.expectEqual(true, 'live' in ds);
el.removeAttribute('data-live');
testing.expectEqual(false, 'live' in ds);
ds.viaSetter = 'y';
testing.expectEqual(true, 'viaSetter' in ds);
delete ds.viaSetter;
testing.expectEqual(false, 'viaSetter' in ds);
// an empty value is still a present property
el.setAttribute('data-empty', '');
testing.expectEqual(true, 'empty' in ds);
testing.expectEqual('', ds.empty);
// the bare data- attribute maps to the empty name
el.setAttribute('data-', 'bare');
testing.expectEqual(true, '' in ds);
testing.expectEqual('bare', ds['']);
// deleting a name that was never there succeeds
testing.expectEqual(true, delete ds.neverThere);
}
</script>
<script id=numericPropertyNames>
{
const el = document.createElement('div');
el.dataset[0] = 'zero';
testing.expectEqual('zero', el.getAttribute('data-0'));
testing.expectEqual('zero', el.dataset[0]);
testing.expectEqual('zero', el.dataset['0']);
testing.expectEqual(true, '0' in el.dataset);
testing.expectEqual(true, Object.hasOwn(el.dataset, '0'));
testing.expectEqual('0', Object.keys(el.dataset).join(','));
delete el.dataset[0];
testing.expectEqual(null, el.getAttribute('data-0'));
testing.expectEqual(undefined, el.dataset[0]);
testing.expectEqual(false, '0' in el.dataset);
el.setAttribute('data-12', 'twelve');
testing.expectEqual('twelve', el.dataset[12]);
testing.expectEqual('twelve', el.dataset['12']);
testing.expectEqual(true, '12' in el.dataset);
testing.expectEqual(false, '13' in el.dataset);
}
</script>
<script id=indexedSetRunsCustomElementReactions>
{
const seen = [];
class DataWatcher extends HTMLElement {
static get observedAttributes() { return ['data-0', 'data-name']; }
attributeChangedCallback(name, oldValue, newValue) {
seen.push(name + '=' + newValue);
}
}
customElements.define('data-watcher', DataWatcher);
const el = document.createElement('data-watcher');
el.dataset.name = 'named';
el.dataset[0] = 'indexed';
delete el.dataset[0];
testing.expectEqual('data-name=named,data-0=indexed,data-0=null', seen.join(','));
}
</script>
<script id=identityCheck>
{
const el = document.getElementById('test');
+28
View File
@@ -5,6 +5,13 @@
<form id="form_with_name" name="myForm"></form>
<form id="form_without_name"></form>
<form id="form_controls">
<input name="user" autocomplete="off">
<input id="pass" type="password" autocomplete="off">
<input type="radio" name="choice" value="1" autocomplete="off">
<input type="radio" name="choice" value="2" autocomplete="off">
</form>
<script id="name_initial">
{
testing.expectEqual('myForm', $('#form_with_name').name)
@@ -661,3 +668,24 @@
testing.expectEqual('InvalidStateError', caught);
}
</script>
<script id="elements_named_presence">
{
const els = $('#form_controls').elements;
testing.expectEqual(true, 'user' in els);
testing.expectEqual(true, 'pass' in els);
testing.expectEqual(true, Object.hasOwn(els, 'choice'));
testing.expectEqual(false, 'nonExistent' in els);
testing.expectEqual(false, Object.hasOwn(els, 'nonExistent'));
testing.expectEqual(false, '' in els);
// a duplicated name is still one present property, backed by a RadioNodeList
testing.expectEqual('RadioNodeList', els.choice.constructor.name);
// [LegacyUnenumerableNamedProperties]
testing.expectEqual(false, Object.keys(els).includes('user'));
testing.expectEqual(false, Object.getOwnPropertyDescriptor(els, 'user').enumerable);
}
</script>
@@ -7,6 +7,11 @@
<option value="val3">Option 3</option>
</select>
<select id="select_named" autocomplete="off">
<option id="opt_first" value="1">First</option>
<option name="second" value="2">Second</option>
</select>
<select id="select2" autocomplete="off">
<option value="a">A</option>
<option value="b">B</option>
@@ -522,3 +527,20 @@
testing.expectEqual(true, orphan.selected);
}
</script>
<script id="options_named_presence">
{
const opts = $('#select_named').options;
testing.expectEqual(true, 'opt_first' in opts);
testing.expectEqual(true, 'second' in opts);
testing.expectEqual(true, Object.hasOwn(opts, 'opt_first'));
testing.expectEqual(false, 'nonExistent' in opts);
testing.expectEqual(false, Object.hasOwn(opts, 'nonExistent'));
// [LegacyUnenumerableNamedProperties]
testing.expectEqual(false, Object.keys(opts).includes('opt_first'));
testing.expectEqual(false, Object.getOwnPropertyDescriptor(opts, 'opt_first').enumerable);
}
</script>
@@ -51,6 +51,16 @@
}
</script>
<script id=navigator_plugins_named_presence>
// No plugin is exposed, so PluginArray has no named properties at all.
testing.expectEqual(false, 'anything' in navigator.plugins);
testing.expectEqual(false, Object.hasOwn(navigator.plugins, 'anything'));
testing.expectEqual(null, navigator.plugins.namedItem('anything'));
// inherited members are still found by `in`
testing.expectEqual(true, 'refresh' in navigator.plugins);
</script>
<script id=navigator_plugins_iterable>
// WebIDL: indexed getter => @@iterator (Chrome: [...navigator.plugins] works)
testing.expectEqual('function', typeof navigator.plugins[Symbol.iterator]);
+15 -7
View File
@@ -78,14 +78,22 @@ pub const JsApi = struct {
pub const length = bridge.property(0, .{ .template = false });
pub const refresh = bridge.function(PluginArray.refresh, .{});
pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, null, null, .{ .null_as_undefined = true });
pub const item = bridge.function(_item, .{});
fn _item(self: *const PluginArray, index: i32) ?*Plugin {
if (index < 0) {
return null;
pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, null, struct {
fn wrap(_: *const PluginArray, _: []const u8) !u32 {
// No plugin is ever exposed, so there are no named properties.
return error.NotHandled;
}
return self.getAtIndex(@intCast(index));
}
}.wrap, .{ .null_as_undefined = true });
pub const item = bridge.function(struct {
fn wrap(self: *const PluginArray, index: i32) ?*Plugin {
if (index < 0) {
return null;
}
return self.getAtIndex(@intCast(index));
}
}.wrap, .{});
pub const namedItem = bridge.function(PluginArray.getByName, .{});
pub const symbol_iterator = bridge.iterator(PluginArray.values, .{});
};
@@ -125,15 +125,24 @@ pub const JsApi = struct {
pub const length = bridge.accessor(HTMLAllCollection.length, null, .{});
pub const @"[int]" = bridge.indexed(HTMLAllCollection.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(HTMLAllCollection.getByName, null, null, null, null, .{ .null_as_undefined = true });
pub const item = bridge.function(_item, .{});
fn _item(self: *HTMLAllCollection, index: i32, frame: *Frame) ?*Element {
if (index < 0) {
return null;
pub const @"[str]" = bridge.namedIndexed(HTMLAllCollection.getByName, null, null, null, struct {
fn wrap(self: *HTMLAllCollection, name: []const u8, frame: *Frame) !u32 {
if (self.getByName(name, frame) != null) {
// Named properties are [LegacyUnenumerableNamedProperties]
return js.v8.DontEnum;
}
return error.NotHandled;
}
return self.getAtIndex(@intCast(index), frame);
}
}.wrap, .{ .null_as_undefined = true });
pub const item = bridge.function(struct {
fn wrap(self: *HTMLAllCollection, index: i32, frame: *Frame) ?*Element {
if (index < 0) {
return null;
}
return self.getAtIndex(@intCast(index), frame);
}
}.wrap, .{});
pub const namedItem = bridge.function(HTMLAllCollection.getByName, .{});
pub const symbol_iterator = bridge.iterator(HTMLAllCollection.iterator, .{});
@@ -74,21 +74,7 @@ pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, frame: *Fr
var it = try self.iterator();
while (it.next()) |element| {
const is_match = blk: {
if (element.getId()) |id| {
if (std.mem.eql(u8, id, name)) {
break :blk true;
}
}
if (element.getName()) |elem_name| {
if (std.mem.eql(u8, elem_name, name)) {
break :blk true;
}
}
break :blk false;
};
if (is_match) {
if (matchesName(element, name)) {
if (first_element == null) {
first_element = element;
}
@@ -126,6 +112,20 @@ pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, frame: *Fr
return .{ .element = first_element.? };
}
fn matchesName(element: *Element, name: []const u8) bool {
if (element.getId()) |id| {
if (std.mem.eql(u8, id, name)) {
return true;
}
}
if (element.getName()) |elem_name| {
if (std.mem.eql(u8, elem_name, name)) {
return true;
}
}
return false;
}
// used internally, by HTMLFormControlsCollection and RadioNodeList
pub fn iterator(self: *HTMLFormControlsCollection) !Iterator {
const form_collection = self._proto._data.form;
@@ -161,6 +161,30 @@ pub const JsApi = struct {
pub const length = bridge.accessor(HTMLFormControlsCollection.length, null, .{});
pub const @"[int]" = bridge.indexed(HTMLFormControlsCollection.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(HTMLFormControlsCollection.namedItem, null, null, null, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(HTMLFormControlsCollection.namedItem, null, null, null, struct {
fn wrap(self: *HTMLFormControlsCollection, name: []const u8) !u32 {
if (try hasNamed(self, name)) {
// Named properties are [LegacyUnenumerableNamedProperties]
return js.v8.DontEnum;
}
return error.NotHandled;
}
}.wrap, .{ .null_as_undefined = true });
pub const namedItem = bridge.function(HTMLFormControlsCollection.namedItem, .{});
// Presence only, `namedItem` is relativel expensive / RC'd
fn hasNamed(self: *HTMLFormControlsCollection, name: []const u8) !bool {
if (name.len == 0) {
return false;
}
var it = try self.iterator();
while (it.next()) |element| {
if (matchesName(element, name)) {
return true;
}
}
return false;
}
};
@@ -118,7 +118,15 @@ pub const JsApi = struct {
// Indexed access
pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, null, null, .{ .null_as_undefined = 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) {
// Named properties are [LegacyUnenumerableNamedProperties]: the query
return js.v8.DontEnum;
}
return error.NotHandled;
}
}.wrap, .{ .null_as_undefined = true });
pub const selectedIndex = bridge.accessor(HTMLOptionsCollection.getSelectedIndex, HTMLOptionsCollection.setSelectedIndex, .{});
pub const add = bridge.function(HTMLOptionsCollection.add, .{ .ce_reactions = true });
+27 -17
View File
@@ -661,26 +661,36 @@ pub const NamedNodeMap = struct {
};
pub const length = bridge.accessor(NamedNodeMap.length, null, .{});
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, frame: *Frame) !js.Array {
const len = self.length();
var arr = frame.js.local.?.newArray(len);
for (0..len) |i| {
_ = try arr.set(@intCast(i), i, .{});
pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, struct {
fn wrap(self: *const NamedNodeMap, frame: *Frame) !js.Array {
const len = self.length();
var arr = frame.js.local.?.newArray(len);
for (0..len) |i| {
_ = try arr.set(@intCast(i), i, .{});
}
return arr;
}
return arr;
}
}.wrap, .{ .null_as_undefined = true });
fn getNames(self: *const NamedNodeMap, frame: *Frame) !js.Array {
const names = try self.list().getNames(frame.local_arena);
var arr = frame.js.local.?.newArray(@intCast(names.len));
for (names, 0..) |name, i| {
_ = try arr.set(@intCast(i), name, .{});
pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, struct {
fn wrap(self: *const NamedNodeMap, frame: *Frame) !js.Array {
const names = try self.list().getNames(frame.local_arena);
var arr = frame.js.local.?.newArray(@intCast(names.len));
for (names, 0..) |name, i| {
_ = try arr.set(@intCast(i), name, .{});
}
return arr;
}
return arr;
}
}.wrap, struct {
fn wrap(self: *const NamedNodeMap, name: String, frame: *Frame) !u32 {
if ((try self.list().get(name, frame)) != null) {
// Named properties are [LegacyUnenumerableNamedProperties]
return js.v8.DontEnum;
}
return error.NotHandled;
}
}.wrap, .{ .null_as_undefined = true });
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 });
@@ -142,6 +142,30 @@ pub const JsApi = struct {
pub const @"[]" = bridge.namedIndexed(getProperty, setProperty, deleteProperty, getNames, hasProperty, .{ .null_as_undefined = true, .ce_reactions = true });
// v8 routes dataset[9] and dataset["9"] to the indexed interceptor
pub const @"[int]" = bridge.indexedReadWrite(_getByIndex, _setByIndex, _deleteByIndex, _queryByIndex, null, .{ .null_as_undefined = true, .ce_reactions = true });
fn _getByIndex(self: *DOMStringMap, idx: u32, frame: *Frame) !?String {
return self._element.getAttribute(try indexName(idx, frame), frame);
}
fn _setByIndex(self: *DOMStringMap, idx: u32, value: String, frame: *Frame) !void {
return self._element.setAttributeSafe(try indexName(idx, frame), value, frame);
}
fn _deleteByIndex(self: *DOMStringMap, idx: u32, frame: *Frame) !void {
return self._element.removeAttribute(try indexName(idx, frame), frame);
}
fn _queryByIndex(self: *DOMStringMap, idx: u32, frame: *Frame) !bool {
return self._element.hasAttribute(try indexName(idx, frame), frame);
}
fn indexName(idx: u32, frame: *Frame) !String {
var buf: [15]u8 = undefined;
const name = std.fmt.bufPrint(&buf, "data-{d}", .{idx}) catch unreachable;
return String.init(frame.local_arena, name, .{});
}
// The supported property names are the camel-cased names of the
// element's data-* attributes, in attribute order.
fn getNames(self: *DOMStringMap, frame: *Frame) !js.Array {