From 42ecdf4d024d4eae9cf652e772001a2a686e87e5 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 8 Jun 2026 22:46:59 +0200 Subject: [PATCH 01/13] webapi: add DataTransfer, DataTransferItem, DataTransferItemList Implements the constructible DataTransfer interface and its item views over a single drag-data-store: string- and file-kind items, with .files (FileList) and .types derived from the file items so items.add(file) is visible immediately. File refs are acquired on add and released on remove/clear; the backing FileList is frame-tracked and the DataTransfer arena is refcounted so the GC finalizer reclaims it, leaving no leaks. Refs #2043 --- src/browser/js/bridge.zig | 1 + src/browser/tests/data_transfer.html | 128 +++++++++ src/browser/webapi/DataTransfer.zig | 291 ++++++++++++++++++++ src/browser/webapi/DataTransferItem.zig | 90 ++++++ src/browser/webapi/DataTransferItemList.zig | 104 +++++++ 5 files changed, 614 insertions(+) create mode 100644 src/browser/tests/data_transfer.html create mode 100644 src/browser/webapi/DataTransfer.zig create mode 100644 src/browser/webapi/DataTransferItem.zig create mode 100644 src/browser/webapi/DataTransferItemList.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 6bcf3a3a5..4d7ad0324 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -980,6 +980,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/File.zig"), @import("../webapi/FileList.zig"), @import("../webapi/FileReader.zig"), + @import("../webapi/DataTransfer.zig"), @import("../webapi/Screen.zig"), @import("../webapi/VisualViewport.zig"), @import("../webapi/PerformanceObserver.zig"), diff --git a/src/browser/tests/data_transfer.html b/src/browser/tests/data_transfer.html new file mode 100644 index 000000000..61ea259ab --- /dev/null +++ b/src/browser/tests/data_transfer.html @@ -0,0 +1,128 @@ + + + Test DataTransfer Web API + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/DataTransfer.zig b/src/browser/webapi/DataTransfer.zig new file mode 100644 index 000000000..28bc78a66 --- /dev/null +++ b/src/browser/webapi/DataTransfer.zig @@ -0,0 +1,291 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); +const Frame = @import("../Frame.zig"); +const Page = @import("../Page.zig"); + +const File = @import("File.zig"); +const FileList = @import("FileList.zig"); +const DataTransferItem = @import("DataTransferItem.zig"); +const DataTransferItemList = @import("DataTransferItemList.zig"); + +const Allocator = std.mem.Allocator; + +// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface +// +// The canonical drag-data-store: one ordered list of items (string- or +// file-kind). `.items` is a live DataTransferItemList view; `.files` is a +// FileList rebuilt from the file-kind items so the two stay in sync. Per v1 +// scope the store is always read/write (no event-phase mode gating). +const DataTransfer = @This(); + +pub fn registerTypes() []const type { + return &.{ + DataTransfer, + DataTransferItem, + DataTransferItemList, + DataTransferItemList.Iterator, + }; +} + +_arena: Allocator, +// Refcounted so the GC weak-finalizer (or page teardown) releases the pooled +// arena exactly once; mirrors Blob's lifecycle. +_rc: lp.RC(u32) = .{}, +_items: std.ArrayList(*DataTransferItem) = .{}, +_item_list: *DataTransferItemList, +// FileList lives on the factory slab and is frame-tracked, so each File ref it +// holds is released at frame teardown (same path as ``). +_files: *FileList, +_drop_effect: []const u8 = "none", +_effect_allowed: []const u8 = "uninitialized", + +pub fn init(frame: *Frame) !*DataTransfer { + const arena = try frame.getArena(.medium, "DataTransfer"); + errdefer frame.releaseArena(arena); + + const fl = try frame._factory.create(FileList{}); + try frame.trackFileList(fl); + + const self = try arena.create(DataTransfer); + const list = try arena.create(DataTransferItemList); + self.* = .{ + ._arena = arena, + ._item_list = list, + ._files = fl, + }; + list.* = .{ ._data_transfer = self }; + return self; +} + +pub fn deinit(self: *DataTransfer, page: *Page) void { + page.releaseArena(self._arena); +} + +pub fn acquireRef(self: *DataTransfer) void { + self._rc.acquire(); +} + +pub fn releaseRef(self: *DataTransfer, page: *Page) void { + self._rc.release(self, page); +} + +// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-getdata +// "text" and "url" are shorthands the spec maps onto MIME types. +fn normalizeFormat(arena: Allocator, format: []const u8) ![]const u8 { + if (std.ascii.eqlIgnoreCase(format, "text")) { + return "text/plain"; + } + if (std.ascii.eqlIgnoreCase(format, "url")) { + return "text/uri-list"; + } + const buf = try arena.dupe(u8, format); + return std.ascii.lowerString(buf, buf); +} + +pub fn getData(self: *const DataTransfer, format: []const u8, frame: *Frame) ![]const u8 { + const norm = try normalizeFormat(frame.call_arena, format); + for (self._items.items) |it| { + if (it._kind == .string and std.mem.eql(u8, it._type, norm)) { + return it._payload.string; + } + } + return ""; +} + +pub fn setData(self: *DataTransfer, format: []const u8, data: []const u8) !void { + const norm = try normalizeFormat(self._arena, format); + const dup = try self._arena.dupe(u8, data); + for (self._items.items) |it| { + if (it._kind == .string and std.mem.eql(u8, it._type, norm)) { + it._payload = .{ .string = dup }; + return; + } + } + const it = try self._arena.create(DataTransferItem); + it.* = .{ ._kind = .string, ._type = norm, ._payload = .{ .string = dup } }; + try self._items.append(self._arena, it); +} + +pub fn clearData(self: *DataTransfer, format_: ?[]const u8, frame: *Frame) !void { + if (format_) |format| { + const norm = try normalizeFormat(frame.call_arena, format); + var i: usize = 0; + while (i < self._items.items.len) { + const it = self._items.items[i]; + if (it._kind == .string and std.mem.eql(u8, it._type, norm)) { + _ = self._items.orderedRemove(i); + } else { + i += 1; + } + } + return; + } + // No format: remove every string item, leave file items in place. + var i: usize = 0; + while (i < self._items.items.len) { + if (self._items.items[i]._kind == .string) { + _ = self._items.orderedRemove(i); + } else { + i += 1; + } + } +} + +// --- DataTransferItemList delegation --- + +// add(File) -> file item ; add(DOMString, DOMString) -> string item. +pub fn addItem(self: *DataTransfer, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem { + if (data.toZig(*File)) |file| { + return try self.addFileItem(file, frame); + } else |_| {} + + const s = try data.toZig([]const u8); + const norm = try normalizeFormat(self._arena, type_ orelse ""); + const dup = try self._arena.dupe(u8, s); + const it = try self._arena.create(DataTransferItem); + it.* = .{ ._kind = .string, ._type = norm, ._payload = .{ .string = dup } }; + try self._items.append(self._arena, it); + return it; +} + +fn addFileItem(self: *DataTransfer, file: *File, frame: *Frame) !*DataTransferItem { + file._proto.acquireRef(); + const it = try self._arena.create(DataTransferItem); + it.* = .{ ._kind = .file, ._type = file._proto.getType(), ._payload = .{ .file = file } }; + try self._items.append(self._arena, it); + try self.rebuildFiles(frame); + return it; +} + +pub fn removeItem(self: *DataTransfer, index: u32, frame: *Frame) !void { + if (index >= self._items.items.len) { + return; + } + const it = self._items.orderedRemove(index); + if (it._kind == .file) { + it._payload.file._proto.releaseRef(frame._page); + try self.rebuildFiles(frame); + } +} + +pub fn clearItems(self: *DataTransfer, frame: *Frame) !void { + for (self._items.items) |it| { + if (it._kind == .file) { + it._payload.file._proto.releaseRef(frame._page); + } + } + self._items.clearRetainingCapacity(); + try self.rebuildFiles(frame); +} + +// Rebuild the FileList slice from the current file-kind items, in order. +fn rebuildFiles(self: *DataTransfer, frame: *Frame) !void { + var files: std.ArrayList(*File) = .{}; + for (self._items.items) |it| { + if (it._kind == .file) { + try files.append(frame.arena, it._payload.file); + } + } + self._files._files = try files.toOwnedSlice(frame.arena); +} + +// --- accessors --- + +pub fn getFiles(self: *DataTransfer) *FileList { + return self._files; +} + +pub fn getItems(self: *DataTransfer) *DataTransferItemList { + return self._item_list; +} + +pub fn getTypes(self: *DataTransfer, frame: *Frame) ![][]const u8 { + var out: std.ArrayList([]const u8) = .{}; + var has_files = false; + for (self._items.items) |it| { + switch (it._kind) { + .string => try out.append(frame.call_arena, it._type), + .file => has_files = true, + } + } + if (has_files) { + try out.append(frame.call_arena, "Files"); + } + return out.toOwnedSlice(frame.call_arena); +} + +pub fn getDropEffect(self: *const DataTransfer) []const u8 { + return self._drop_effect; +} + +pub fn setDropEffect(self: *DataTransfer, value: []const u8) !void { + inline for (.{ "none", "copy", "link", "move" }) |valid| { + if (std.mem.eql(u8, value, valid)) { + self._drop_effect = valid; + return; + } + } +} + +pub fn getEffectAllowed(self: *const DataTransfer) []const u8 { + return self._effect_allowed; +} + +pub fn setEffectAllowed(self: *DataTransfer, value: []const u8) !void { + inline for (.{ "none", "copy", "copyLink", "copyMove", "link", "linkMove", "move", "all", "uninitialized" }) |valid| { + if (std.mem.eql(u8, value, valid)) { + self._effect_allowed = valid; + return; + } + } +} + +// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransfer-setdragimage +// No-op: Lightpanda has no rendered drag feedback. +pub fn setDragImage(_: *DataTransfer, _: js.Value, _: i32, _: i32) void {} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DataTransfer); + + pub const Meta = struct { + pub const name = "DataTransfer"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DataTransfer.init, .{}); + pub const dropEffect = bridge.accessor(DataTransfer.getDropEffect, DataTransfer.setDropEffect, .{}); + pub const effectAllowed = bridge.accessor(DataTransfer.getEffectAllowed, DataTransfer.setEffectAllowed, .{}); + pub const files = bridge.accessor(DataTransfer.getFiles, null, .{}); + pub const items = bridge.accessor(DataTransfer.getItems, null, .{}); + pub const types = bridge.accessor(DataTransfer.getTypes, null, .{}); + pub const getData = bridge.function(DataTransfer.getData, .{}); + pub const setData = bridge.function(DataTransfer.setData, .{}); + pub const clearData = bridge.function(DataTransfer.clearData, .{}); + pub const setDragImage = bridge.function(DataTransfer.setDragImage, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: DataTransfer" { + try testing.htmlRunner("data_transfer.html", .{}); +} diff --git a/src/browser/webapi/DataTransferItem.zig b/src/browser/webapi/DataTransferItem.zig new file mode 100644 index 000000000..2ee1eaa8c --- /dev/null +++ b/src/browser/webapi/DataTransferItem.zig @@ -0,0 +1,90 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); + +const File = @import("File.zig"); + +const log = lp.log; + +// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitem-interface +const DataTransferItem = @This(); + +pub const Kind = enum { string, file }; + +_kind: Kind, +// For string items: the normalized format (e.g. "text/plain"). +// For file items: the File's MIME type. +_type: []const u8, +_payload: Payload, + +pub const Payload = union(Kind) { + string: []const u8, + file: *File, +}; + +pub fn getKind(self: *const DataTransferItem) []const u8 { + return switch (self._kind) { + .string => "string", + .file => "file", + }; +} + +pub fn getType(self: *const DataTransferItem) []const u8 { + return self._type; +} + +pub fn getAsFile(self: *const DataTransferItem) ?*File { + return switch (self._payload) { + .file => |f| f, + .string => null, + }; +} + +// https://html.spec.whatwg.org/multipage/dnd.html#dom-datatransferitem-getasstring +// v1 invokes the callback synchronously with the string value. File items and a +// missing callback are no-ops, per spec. +pub fn getAsString(self: *const DataTransferItem, cb_: ?js.Function) !void { + const cb = cb_ orelse return; + const s = switch (self._payload) { + .string => |str| str, + .file => return, + }; + var caught: js.TryCatch.Caught = undefined; + cb.tryCall(void, .{s}, &caught) catch { + log.debug(.js, "getAsString callback", .{ .caught = caught, .source = "DataTransferItem" }); + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DataTransferItem); + + pub const Meta = struct { + pub const name = "DataTransferItem"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const kind = bridge.accessor(DataTransferItem.getKind, null, .{}); + pub const @"type" = bridge.accessor(DataTransferItem.getType, null, .{}); + pub const getAsFile = bridge.function(DataTransferItem.getAsFile, .{}); + pub const getAsString = bridge.function(DataTransferItem.getAsString, .{}); +}; diff --git a/src/browser/webapi/DataTransferItemList.zig b/src/browser/webapi/DataTransferItemList.zig new file mode 100644 index 000000000..a69af1ea2 --- /dev/null +++ b/src/browser/webapi/DataTransferItemList.zig @@ -0,0 +1,104 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); +const Frame = @import("../Frame.zig"); + +const DataTransfer = @import("DataTransfer.zig"); +const DataTransferItem = @import("DataTransferItem.zig"); + +// https://html.spec.whatwg.org/multipage/dnd.html#the-datatransferitemlist-interface +// +// A live view over the owning DataTransfer's item list; all mutations are +// delegated to the DataTransfer so `.files` stays in sync. +const DataTransferItemList = @This(); + +_data_transfer: *DataTransfer, + +pub fn getLength(self: *const DataTransferItemList) u32 { + return @intCast(self._data_transfer._items.items.len); +} + +pub fn item(self: *const DataTransferItemList, index: u32) ?*DataTransferItem { + const items = self._data_transfer._items.items; + if (index >= items.len) { + return null; + } + return items[index]; +} + +// add(DOMString data, DOMString type) | add(File data) +// The overload is resolved by inspecting the first argument: a File yields a +// file item (the `type` argument is ignored), anything else a string item. +pub fn add(self: *DataTransferItemList, data: js.Value, type_: ?[]const u8, frame: *Frame) !?*DataTransferItem { + return self._data_transfer.addItem(data, type_, frame); +} + +pub fn remove(self: *DataTransferItemList, index: u32, frame: *Frame) !void { + return self._data_transfer.removeItem(index, frame); +} + +pub fn clear(self: *DataTransferItemList, frame: *Frame) !void { + return self._data_transfer.clearItems(frame); +} + +pub fn iterator(self: *DataTransferItemList, exec: *const js.Execution) !*Iterator { + return Iterator.init(.{ + .index = 0, + .list = self, + }, exec); +} + +const GenericIterator = @import("collections/iterator.zig").Entry; +pub const Iterator = GenericIterator(struct { + index: u32, + list: *DataTransferItemList, + + pub fn next(self: *@This(), _: *const js.Execution) ?*DataTransferItem { + const index = self.index; + const it = self.list.item(index) orelse return null; + self.index = index + 1; + return it; + } +}, null); + +pub const JsApi = struct { + pub const bridge = js.Bridge(DataTransferItemList); + + pub const Meta = struct { + pub const name = "DataTransferItemList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(DataTransferItemList.getLength, null, .{}); + pub const add = bridge.function(DataTransferItemList.add, .{}); + pub const remove = bridge.function(DataTransferItemList.remove, .{}); + pub const clear = bridge.function(DataTransferItemList.clear, .{}); + pub const @"[]" = bridge.indexed(DataTransferItemList.item, getIndexes, .{ .null_as_undefined = true }); + pub const symbol_iterator = bridge.iterator(DataTransferItemList.iterator, .{}); + + fn getIndexes(self: *DataTransferItemList, exec: *const js.Execution) !js.Array { + const len = self.getLength(); + var arr = exec.js.local.?.newArray(len); + for (0..len) |i| { + _ = try arr.set(@intCast(i), i, .{}); + } + return arr; + } +}; From ff1fb2dc0d8daca05ac449e39c8320b799f9770c Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 8 Jun 2026 22:50:18 +0200 Subject: [PATCH 02/13] webapi: add DragEvent carrying dataTransfer; wire InputEvent.dataTransfer DragEvent extends MouseEvent (new drag_event arm in the MouseEvent type union) and exposes a nullable dataTransfer, so JS can build a DataTransfer and dispatch a drop/dragover event whose .dataTransfer.files is readable. Also resolves the InputEvent dataTransfer TODO with a real field + accessor. Both events hold a refcount on the DataTransfer for their lifetime and release it in deinit (via the leaf acquireRef/releaseRef/deinit trio, like MessageEvent holds a Blob), so the store's arena can't be freed out from under an event whose JS wrapper is collected first. Refs #2043 --- src/browser/js/bridge.zig | 1 + src/browser/tests/event/drag.html | 69 ++++++++++++ src/browser/webapi/event/DragEvent.zig | 134 ++++++++++++++++++++++++ src/browser/webapi/event/InputEvent.zig | 33 +++++- src/browser/webapi/event/MouseEvent.zig | 4 +- 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 src/browser/tests/event/drag.html create mode 100644 src/browser/webapi/event/DragEvent.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 4d7ad0324..72cb14a69 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -926,6 +926,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/event/KeyboardEvent.zig"), @import("../webapi/event/FocusEvent.zig"), @import("../webapi/event/WheelEvent.zig"), + @import("../webapi/event/DragEvent.zig"), @import("../webapi/event/TextEvent.zig"), @import("../webapi/event/InputEvent.zig"), @import("../webapi/event/PromiseRejectionEvent.zig"), diff --git a/src/browser/tests/event/drag.html b/src/browser/tests/event/drag.html new file mode 100644 index 000000000..db3ddaa0c --- /dev/null +++ b/src/browser/tests/event/drag.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + diff --git a/src/browser/webapi/event/DragEvent.zig b/src/browser/webapi/event/DragEvent.zig new file mode 100644 index 000000000..fe0def984 --- /dev/null +++ b/src/browser/webapi/event/DragEvent.zig @@ -0,0 +1,134 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); + +const Event = @import("../Event.zig"); +const MouseEvent = @import("MouseEvent.zig"); +const DataTransfer = @import("../DataTransfer.zig"); + +const String = lp.String; + +const DragEvent = @This(); + +_proto: *MouseEvent, +_data_transfer: ?*DataTransfer, + +pub const DragEventOptions = struct { + dataTransfer: ?*DataTransfer = null, +}; + +pub const Options = Event.inheritOptions(DragEvent, DragEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*DragEvent { + return initWithTrusted(typ, _opts, false, frame); +} + +pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*DragEvent { + return initWithTrusted(typ, _opts, true, frame); +} + +fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*DragEvent { + const arena = try frame.getArena(.medium, "DragEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + + const event = try frame._factory.mouseEvent( + arena, + type_string, + MouseEvent{ + ._type = .{ .drag_event = undefined }, + ._proto = undefined, + ._screen_x = opts.screenX, + ._screen_y = opts.screenY, + ._client_x = opts.clientX, + ._client_y = opts.clientY, + ._ctrl_key = opts.ctrlKey, + ._shift_key = opts.shiftKey, + ._alt_key = opts.altKey, + ._meta_key = opts.metaKey, + ._button = std.meta.intToEnum(MouseEvent.MouseButton, opts.button) catch return error.TypeError, + ._buttons = opts.buttons, + ._related_target = opts.relatedTarget, + }, + DragEvent{ + ._proto = undefined, + ._data_transfer = opts.dataTransfer, + }, + ); + + Event.populatePrototypes(event, opts, trusted); + + // Hold a ref on the DataTransfer so its arena outlives this event even if the + // JS wrapper is collected first; released in deinit (mirrors MessageEvent's + // Blob handling). The shared refcount lives on the Event base, so reach it + // through asEvent() rather than the immediate _proto. + if (opts.dataTransfer) |dt| { + dt.acquireRef(); + } + + return event; +} + +pub fn deinit(self: *DragEvent, page: *Page) void { + if (self._data_transfer) |dt| { + dt.releaseRef(page); + } + self.asEvent().deinit(page); +} + +pub fn acquireRef(self: *DragEvent) void { + self.asEvent().acquireRef(); +} + +pub fn releaseRef(self: *DragEvent, page: *Page) void { + self.asEvent()._rc.release(self, page); +} + +pub fn asEvent(self: *DragEvent) *Event { + return self._proto.asEvent(); +} + +pub fn getDataTransfer(self: *const DragEvent) ?*DataTransfer { + return self._data_transfer; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DragEvent); + + pub const Meta = struct { + pub const name = "DragEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DragEvent.init, .{}); + pub const dataTransfer = bridge.accessor(DragEvent.getDataTransfer, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: DragEvent" { + try testing.htmlRunner("event/drag.html", .{}); +} diff --git a/src/browser/webapi/event/InputEvent.zig b/src/browser/webapi/event/InputEvent.zig index c74e8e037..f942c161e 100644 --- a/src/browser/webapi/event/InputEvent.zig +++ b/src/browser/webapi/event/InputEvent.zig @@ -21,9 +21,11 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); +const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); const UIEvent = @import("UIEvent.zig"); +const DataTransfer = @import("../DataTransfer.zig"); const String = lp.String; const Allocator = std.mem.Allocator; @@ -32,12 +34,13 @@ const InputEvent = @This(); _proto: *UIEvent, _data: ?[]const u8, -// TODO: add dataTransfer +_data_transfer: ?*DataTransfer = null, _input_type: []const u8, _is_composing: bool, pub const InputEventOptions = struct { data: ?[]const u8 = null, + dataTransfer: ?*DataTransfer = null, inputType: ?[]const u8 = null, isComposing: bool = false, }; @@ -69,6 +72,7 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool InputEvent{ ._proto = undefined, ._data = if (opts.data) |d| try arena.dupe(u8, d) else null, + ._data_transfer = opts.dataTransfer, ._input_type = if (opts.inputType) |it| try arena.dupe(u8, it) else "", ._is_composing = opts.isComposing, }, @@ -82,9 +86,31 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool rootevt._cancelable = false; rootevt._composed = true; + // Hold a ref on the DataTransfer (when present) for this event's lifetime; + // released in deinit. Almost always null for input events, but keeps the + // refcount protocol intact when it isn't. + if (opts.dataTransfer) |dt| { + dt.acquireRef(); + } + return event; } +pub fn deinit(self: *InputEvent, page: *Page) void { + if (self._data_transfer) |dt| { + dt.releaseRef(page); + } + self.asEvent().deinit(page); +} + +pub fn acquireRef(self: *InputEvent) void { + self.asEvent().acquireRef(); +} + +pub fn releaseRef(self: *InputEvent, page: *Page) void { + self.asEvent()._rc.release(self, page); +} + pub fn asEvent(self: *InputEvent) *Event { return self._proto.asEvent(); } @@ -93,6 +119,10 @@ pub fn getData(self: *const InputEvent) ?[]const u8 { return self._data; } +pub fn getDataTransfer(self: *const InputEvent) ?*DataTransfer { + return self._data_transfer; +} + pub fn getInputType(self: *const InputEvent) []const u8 { return self._input_type; } @@ -112,6 +142,7 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(InputEvent.init, .{}); pub const data = bridge.accessor(InputEvent.getData, null, .{}); + pub const dataTransfer = bridge.accessor(InputEvent.getDataTransfer, null, .{}); pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{}); pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{}); }; diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index 240ce6c81..04a07ffed 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -45,6 +45,7 @@ pub const Type = union(enum) { generic, pointer_event: *PointerEvent, wheel_event: *@import("WheelEvent.zig"), + drag_event: *@import("DragEvent.zig"), }; _type: Type, @@ -135,6 +136,7 @@ pub fn is(self: *MouseEvent, comptime T: type) ?*T { .generic => return if (T == MouseEvent) self else null, .pointer_event => |e| return if (T == PointerEvent) e else null, .wheel_event => |e| return if (T == @import("WheelEvent.zig")) e else null, + .drag_event => |e| return if (T == @import("DragEvent.zig")) e else null, } return null; } From c81b4d13d966ecb0d364647cf7ea7ebb7496ed30 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 9 Jun 2026 15:16:34 +0800 Subject: [PATCH 03/13] perf,http: Support for script preloading Implement . The implementation is similar to preloadImport / waitForImport. Consider a website that does something like: ``` // pseudo html ``` Then, without preloading, we hit and block while we load it + execute it. Repeat for 2, 3, 4 ... With preloading, by the time we block on all the scripts are already being downloaded in the background. I opted to remove the script on first use. If a script happens to be used twice (we have seen this happen for imports, but I guess it's more rare on blocking scripts), then it'll get re-downloaded the 2nd time, just like before (and just like before, the http cache is a better mechanism to rely on here). airbnb preloads 41 scripts. --- src/browser/Frame.zig | 17 ++ src/browser/ScriptManager.zig | 204 +++++++++++++++++++++-- src/browser/ScriptManagerBase.zig | 6 + src/browser/webapi/element/html/Link.zig | 34 ++-- 4 files changed, 233 insertions(+), 28 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 616670861..70910efab 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1697,6 +1697,23 @@ pub fn queueLoad(self: *Frame, html: *Element.Html) !void { // splitting by route anyway). const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024; +// start prefetching ` +pub fn preloadScriptHint(self: *Frame, href: []const u8) void { + if (self.isGoingAway() or self._parse_mode == .fragment) { + return; + } + + const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return; + defer self.releaseArena(arena); + + const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return; + if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) { + // data:/blob: are synthesized locally — no round-trip to hide. + return; + } + self._script_manager.preloadScript(resolved) catch {}; +} + // Synchronously fetch and parse an external ``. // href is passed in as an optimization since the [currently] only callsite has // it, so why look it up again? diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 346e3f373..298bf317d 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -46,25 +46,40 @@ frame: *Frame, // "load" event). frame_notified_of_completion: bool, +// scripts loaded based on a found during parsing +preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript), + pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager { var base = ScriptManagerBase.init(allocator, http_client, .{ .frame = frame }); base.tail_hook = tailHook; return .{ - .frame = frame, .base = base, + .frame = frame, + .preloaded_scripts = .empty, .frame_notified_of_completion = false, }; } pub fn deinit(self: *ScriptManager) void { + self.freeDonePreloads(); self.base.deinit(); + self.preloaded_scripts.deinit(self.base.allocator); } pub fn reset(self: *ScriptManager) void { + self.freeDonePreloads(); + self.preloaded_scripts.clearRetainingCapacity(); self.base.reset(); self.frame_notified_of_completion = false; } +fn freeDonePreloads(self: *ScriptManager) void { + var it = self.preloaded_scripts.valueIterator(); + while (it.next()) |preload_script| { + preload_script.deinit(); + } +} + // Frame wrapper uses this to fire documentIsLoaded and scriptsCompletedLoading // once Base has finished processing its ready / defer queues. pub fn tailHook(base: *ScriptManagerBase) void { @@ -86,6 +101,95 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers { return self.base.getHeaders(); } +pub fn preloadScript(self: *ScriptManager, url: []const u8) !void { + if (self.preloaded_scripts.contains(url)) { + return; + } + + const frame = self.frame; + const arena = try frame.getArena(.medium, "SM.preloadScript"); + errdefer frame.releaseArena(arena); + + const owned_url = try arena.dupeZ(u8, url); + + const script = try arena.create(Script); + script.* = .{ + .arena = arena, + .url = owned_url, + .node = .{}, + .manager = &self.base, + .complete = false, + .source = .{ .remote = .{} }, + .extra = .preload, + }; + + try self.preloaded_scripts.putNoClobber(self.base.allocator, owned_url, .{}); + errdefer _ = self.preloaded_scripts.remove(owned_url); + + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ .url = owned_url, .ctx = "preload" }); + } + + // Tracked in async_scripts only while in flight so shutdown/reset can free a + // never-consumed preload; preloadDoneCallback moves ownership to the map. + self.base.async_scripts.append(&script.node); + + // Guard against a synchronous completion re-entering evaluate() mid-parse, + // the same reason getAsyncImport does. + const was_evaluating = self.base.is_evaluating; + self.base.is_evaluating = true; + defer self.base.is_evaluating = was_evaluating; + + frame.makeRequest(.{ + .ctx = script, + .url = owned_url, + .method = .GET, + .frame_id = frame._frame_id, + .loader_id = frame._loader_id, + .headers = try self.base.getHeaders(), + .cookie_jar = &frame._session.cookie_jar, + .cookie_origin = frame.url, + .resource_type = .script, + .notification = frame._session.notification, + .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, + .header_callback = Script.headerCallback, + .data_callback = Script.dataCallback, + .done_callback = PreloadedScript.doneCallback, + .error_callback = PreloadedScript.errorCallback, + }) catch |err| { + self.base.async_scripts.remove(&script.node); + return err; + }; +} + +fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script { + if (self.preloaded_scripts.getPtr(url) == null) { + return null; + } + + const was_evaluating = self.base.is_evaluating; + self.base.is_evaluating = true; + defer self.base.is_evaluating = was_evaluating; + + var client = self.base.client; + while (true) { + const entry = self.preloaded_scripts.getPtr(url) orelse return null; + switch (entry.state) { + .loading => { + _ = client.tick(200, .sync_wait) catch return null; + continue; + }, + .done => |script| { + // Preload scripts are single-use. We return it and it becomes + // the caller's responsibility to free. + _ = self.preloaded_scripts.remove(url); + return script; + }, + .err => return null, + } + } +} + pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void { if (script_element._executed) { // If a script tag gets dynamically created and added to the dom: @@ -132,6 +236,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e var handover = false; const frame = self.frame; + // A consumed preload (waitForPreload below) is owned by us: its buffer is + // borrowed by `script`, so it must outlive eval. + var consumed_preload: ?*Script = null; + defer if (consumed_preload) |p| { + p.deinit(); + }; + const arena = try frame.getArena(.large, "SM.addFromElement"); errdefer if (!handover) { frame.releaseArena(arena); @@ -237,24 +348,30 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e self.base.is_evaluating = true; defer self.base.is_evaluating = was_evaluating; - const headers = try self.getHeaders(); - if (is_blocking) { - const response = try self.base.client.syncRequest(arena, .{ - .url = url, - .method = .GET, - .frame_id = frame._frame_id, - .loader_id = frame._loader_id, - .headers = headers, - .cookie_jar = &frame._session.cookie_jar, - .cookie_origin = frame.url, - .resource_type = .script, - .notification = frame._session.notification, - }); + if (self.waitForPreload(url)) |pre| { + // There was a preloaded script, we borrow it's source and status + consumed_preload = pre; + script.source = pre.source; + script.status = pre.status; + script.complete = true; + } else { + const response = try self.base.client.syncRequest(arena, .{ + .url = url, + .method = .GET, + .frame_id = frame._frame_id, + .loader_id = frame._loader_id, + .headers = try self.getHeaders(), + .cookie_jar = &frame._session.cookie_jar, + .cookie_origin = frame.url, + .resource_type = .script, + .notification = frame._session.notification, + }); - script.source = .{ .remote = response.body }; - script.status = response.status; - script.complete = true; + script.source = .{ .remote = response.body }; + script.status = response.status; + script.complete = true; + } } else { errdefer { self.base.scriptList(script).remove(&script.node); @@ -267,7 +384,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e .method = .GET, .frame_id = frame._frame_id, .loader_id = frame._loader_id, - .headers = headers, + .headers = try self.getHeaders(), .cookie_jar = &frame._session.cookie_jar, .cookie_origin = frame.url, .resource_type = .script, @@ -311,3 +428,54 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e pub fn staticScriptsDone(self: *ScriptManager) void { self.base.staticScriptsDone(); } + +const PreloadedScript = struct { + state: State = .loading, + + const State = union(enum) { + err, + loading, + done: *Script, + }; + + pub fn deinit(self: PreloadedScript) void { + switch (self.state) { + .done => |script| script.deinit(), + else => {}, + } + } + + fn doneCallback(ctx: *anyopaque) !void { + const script: *Script = @ptrCast(@alignCast(ctx)); + script.complete = true; + if (comptime IS_DEBUG) { + log.debug(.http, "script fetch complete", .{ .req = script.url }); + } + + const self: *ScriptManager = @fieldParentPtr("base", script.manager); + // Hand ownership to the map; the blocking path adopts the body via + // waitForPreload, and reset() frees the .done entry. + self.base.async_scripts.remove(&script.node); + self.preloaded_scripts.getPtr(script.url).?.state = .{ .done = script }; + + self.base.evaluate(); + } + + fn errorCallback(ctx: *anyopaque, err: anyerror) void { + const script: *Script = @ptrCast(@alignCast(ctx)); + if (script.status == 404) { + log.info(.http, "script 404", .{ .req = script.url, .extra = "preload" }); + } else { + log.warn(.http, "script fetch error", .{ .err = err, .req = script.url, .extra = "preload", .status = script.status }); + } + + const self: *ScriptManager = @fieldParentPtr("base", script.manager); + self.base.async_scripts.remove(&script.node); + _ = self.preloaded_scripts.remove(script.url); + script.deinit(); + + if (self.base.shutdown == false) { + self.base.evaluate(); + } + } +}; diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 2eaafd668..e3cc3b009 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -193,6 +193,7 @@ fn releaseArena(self: *ScriptManagerBase, arena: Allocator) void { pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLinkedList { return switch (script.extra) { .import, .import_async => &self.async_scripts, + .preload => unreachable, // done/error are handled directly, never via scriptList .frame => |fe| switch (fe.mode) { .normal => unreachable, // not added to a list, executed immediately .@"defer" => &self.defer_scripts, @@ -433,6 +434,7 @@ pub fn evaluate(self: *ScriptManagerBase) void { } }, .import => unreachable, // .import doesn't go through ready_scripts + .preload => unreachable, // .preload is buffered in the map, never queued } } @@ -505,6 +507,8 @@ pub const Script = struct { import, // Dynamic JS import() — resolved via ready_scripts callback. import_async: ImportAsync, + // + preload, // + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/script/preload.js b/src/browser/tests/element/html/script/preload.js new file mode 100644 index 000000000..658e1e9f0 --- /dev/null +++ b/src/browser/tests/element/html/script/preload.js @@ -0,0 +1,2 @@ +order += 'a'; +testing.expectEqual('a', order); diff --git a/src/browser/tests/element/html/script/preload_unused.js b/src/browser/tests/element/html/script/preload_unused.js new file mode 100644 index 000000000..f0d04b8a1 --- /dev/null +++ b/src/browser/tests/element/html/script/preload_unused.js @@ -0,0 +1,4 @@ +// Nothing consumes this preload, so it must never be evaluated. If it runs, the +// flag trips the assertion in preload.html (and this fail() fires directly). +window.unused_preload_ran = true; +testing.fail('an unconsumed must not execute'); From a52c45f309930413a20416c7e9a582a1fe8c06cb Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 9 Jun 2026 17:40:08 +0200 Subject: [PATCH 07/13] mcp: ping's response must be an object not an array The array returned breaks the mcp keep alive feature implemented by the official mcp client in Go. --- src/mcp/Server.zig | 2 +- src/mcp/router.zig | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index 9a5a285eb..327810103 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -137,5 +137,5 @@ test "MCP.Server - Integration: ping request returns an empty result" { try router.processRequests(server, &in_reader); - try testing.expectJson(.{ .jsonrpc = "2.0", .id = "ping-1", .result = .{} }, out_alloc.writer.buffered()); + try testing.expectJson(.{ .jsonrpc = "2.0", .id = "ping-1", .result = struct {}{} }, out_alloc.writer.buffered()); } diff --git a/src/mcp/router.zig b/src/mcp/router.zig index d3feec5fc..ea5732e69 100644 --- a/src/mcp/router.zig +++ b/src/mcp/router.zig @@ -100,7 +100,7 @@ fn handleInitialize(server: *Server, req: protocol.Request) !void { fn handlePing(server: *Server, req: protocol.Request) !void { const id = req.id orelse return; - try server.sendResult(id, .{}); + try server.sendResult(id, struct {}{}); } const testing = @import("../testing.zig"); @@ -131,7 +131,7 @@ test "MCP.router - handleMessage - synchronous unit tests" { try handleMessage(server, aa, \\{"jsonrpc":"2.0","id":2,"method":"ping"} ); - try testing.expectJson(.{ .jsonrpc = "2.0", .id = 2, .result = .{} }, out_alloc.writer.buffered()); + try testing.expectJson(.{ .jsonrpc = "2.0", .id = 2, .result = struct {}{} }, out_alloc.writer.buffered()); out_alloc.writer.end = 0; // 3. Tools list From caeb359f75ec56e5cd488e388185d3f939d08115 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 10 Jun 2026 15:28:11 +0800 Subject: [PATCH 08/13] wpt, shadowdom: Improve shadowdom This was driven by various WPT tests in the /shadow-dom/ category. There are 4 distinct changes. 1. Template hooks into "cloned" to include the _content. This change required passing `deep: bool` which is why TextArea and Input are also changed (they ignore that new parameter) 2. Node.getRootNode and Node.ownerDocument will now traverse through the ShadowRoot to find the root/document 3. DOM events won't gain the Window when triggered from within a ShadowRoot 4. attachShadow now takes a full option, not just a string mode. This also touched a few different places since it's called internally too. --- src/browser/EventManager.zig | 43 ++++++-- src/browser/EventManagerBase.zig | 3 + src/browser/markdown.zig | 2 +- src/browser/parser/Parser.zig | 15 ++- src/browser/tests/element/html/template.html | 22 ++++ src/browser/tests/shadowroot/declarative.html | 93 ++++++++++++++++ src/browser/tests/shadowroot/events.html | 45 ++++++++ src/browser/tests/shadowroot/scoping.html | 16 +++ src/browser/tests/window/report_error.html | 3 +- src/browser/webapi/Element.zig | 72 ++++++++++--- src/browser/webapi/Event.zig | 26 +++-- src/browser/webapi/Node.zig | 12 ++- src/browser/webapi/ShadowRoot.zig | 48 ++++++++- src/browser/webapi/element/html/Input.zig | 3 +- src/browser/webapi/element/html/Script.zig | 3 +- src/browser/webapi/element/html/Template.zig | 100 +++++++++++++++++- src/browser/webapi/element/html/TextArea.zig | 3 +- 17 files changed, 467 insertions(+), 42 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index b88b6d045..e6b96de63 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -176,12 +176,21 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts const activation_state = try ActivationState.create(event, target, frame); + var path_len: usize = 0; + var node_path_len: usize = 0; + var path_buffer: [128]*EventTarget = undefined; + // Defer runs even on early return - ensures event phase is reset // and default actions execute (unless prevented) defer { event._event_phase = .none; + event._current_target = null; event._stop_propagation = false; event._stop_immediate_propagation = false; + if (event._needs_retargeting and node_path_len > 0) { + const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]); + event._target = if (rootIsShadowRoot(adjusted)) null else adjusted; + } // Handle checkbox/radio activation rollback or commit if (activation_state) |state| { state.restore(event, frame); @@ -201,9 +210,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } - var path_len: usize = 0; - var path_buffer: [128]*EventTarget = undefined; - var node: ?*Node = target; while (node) |n| { if (path_len >= path_buffer.len) break; @@ -227,11 +233,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts node = n._parent; } + node_path_len = path_len; + // Even though the window isn't part of the DOM, most events propagate - // through it in the capture phase (unless we stopped at a shadow boundary) - // The only explicit exception is "load" - if (event._type_string.eql(comptime .wrap("load")) == false) { - if (path_len < path_buffer.len) { + // through it in the capture phase. It only participates when the tree's + // root is the document (not for detached trees, and not when propagation + // stopped at a shadow boundary). The only explicit exception is "load". + if (event._type_string.eql(comptime .wrap("load")) == false and path_len < path_buffer.len) { + const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) { + .node => |n| n._type == .document, + else => false, + }; + if (root_is_document) { path_buffer[path_len] = frame.window.asEventTarget(); path_len += 1; } @@ -454,6 +467,22 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge return original_target; } +// Whether the target's tree root (without crossing shadow boundaries) is a +// shadow root. Used for the spec's post-dispatch "clear targets" step. +fn rootIsShadowRoot(target_: ?*EventTarget) bool { + const ShadowRoot = @import("webapi/ShadowRoot.zig"); + + const target = target_ orelse return false; + var current: *Node = switch (target._type) { + .node => |n| n, + else => return false, + }; + while (current._parent) |p| { + current = p; + } + return current.is(ShadowRoot) != null; +} + // Check if ancestor is an ancestor of (or the same as) node // WITHOUT crossing shadow boundaries (just regular DOM tree) fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool { diff --git a/src/browser/EventManagerBase.zig b/src/browser/EventManagerBase.zig index 5c09a6c5b..d736ec2ba 100644 --- a/src/browser/EventManagerBase.zig +++ b/src/browser/EventManagerBase.zig @@ -253,6 +253,9 @@ pub fn dispatchDirect( ls.deinit(); } + // Per spec, currentTarget is only set while listeners are being invoked + defer event._current_target = null; + // Call the property handler (e.g., onmessage) if present if (getFunction(handler, &ls.local)) |func| { event._current_target = target; diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 66a1f89d9..32cfc0846 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -775,7 +775,7 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u try frame.parseHtmlAsChildren(host.asNode(), light); } - const sr = try host.attachShadow(comptime .wrap("open"), frame); + const sr = try host.attachShadow(.{ .mode = .open }, frame); try frame.parseHtmlAsChildren(sr.asNode(), shadow); var aw: std.Io.Writer.Allocating = .init(testing.allocator); diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index d83018ad5..be02eb8e5 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -579,14 +579,19 @@ fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, templa fn _attachDeclarativeShadowCallback(self: *Parser, host_node: *Node, template_node: *Node, mode_is_open: bool) !u8 { // guaranteed by html5ever const host = host_node.as(Element); - const mode: lp.String = if (mode_is_open) comptime .wrap("open") else comptime .wrap("closed"); - const shadow = host.attachShadow(mode, self.frame) catch |err| switch (err) { - // Expected per-spec fall-backs (host can't host a shadow, or already - // has one): keep the