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/Frame.zig b/src/browser/Frame.zig index 693e731cc..41405779f 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1703,6 +1703,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? @@ -1751,7 +1768,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co const sm = &self._script_manager.base; const was_evaluating = sm.is_evaluating; sm.is_evaluating = true; - defer sm.is_evaluating = was_evaluating; + defer sm.endEvaluationWindow(was_evaluating); var response = http_client.syncRequest(arena, .{ .url = resolved, diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 346e3f373..769d57f45 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -46,25 +46,41 @@ 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.freePreloads(); self.base.deinit(); + self.preloaded_scripts.deinit(self.base.allocator); } pub fn reset(self: *ScriptManager) void { + self.freePreloads(); + self.preloaded_scripts.clearRetainingCapacity(); self.base.reset(); self.frame_notified_of_completion = false; } +// Frees every preloaded Script +fn freePreloads(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 +102,81 @@ 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(.large, "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, .{ .state = .{ .loading = script } }); + errdefer _ = self.preloaded_scripts.remove(owned_url); + + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ .url = owned_url, .ctx = "preload" }); + } + + try 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, + }); +} + +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.endEvaluationWindow(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; + }, + } + } +} + 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 +223,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); @@ -235,26 +333,32 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e const was_evaluating = self.base.is_evaluating; self.base.is_evaluating = true; - defer self.base.is_evaluating = was_evaluating; - - const headers = try self.getHeaders(); + defer self.base.endEvaluationWindow(was_evaluating); 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 +371,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, @@ -301,8 +405,8 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e const was_evaluating = self.base.is_evaluating; self.base.is_evaluating = true; defer { - self.base.is_evaluating = was_evaluating; script.deinit(); + self.base.endEvaluationWindow(was_evaluating); } script.eval(); @@ -311,3 +415,42 @@ 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, + + const State = union(enum) { + loading: *Script, + done: *Script, + }; + + pub fn deinit(self: PreloadedScript) void { + switch (self.state) { + inline else => |script| script.deinit(), + } + } + + 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); + self.preloaded_scripts.getPtr(script.url).?.state = .{ .done = script }; + } + + 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.preloaded_scripts.remove(script.url); + script.deinit(); + } +}; diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 2eaafd668..f917ced60 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -95,6 +95,10 @@ owner: Owner, // used to prevent recursive evaluation is_evaluating: bool, +// an evaluate() arrived while is_evaluating was set, signal to call evaluate() +// when is_evaluating becomes false (to prevent re-entrancy) +evaluate_pending: bool = false, + // Only once this is true can deferred scripts be run static_scripts_done: bool, @@ -167,6 +171,7 @@ pub fn reset(self: *ScriptManagerBase) void { clearList(&self.async_scripts); clearList(&self.ready_scripts); self.static_scripts_done = false; + self.evaluate_pending = false; } fn clearList(list: *std.DoublyLinkedList) void { @@ -193,6 +198,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, @@ -287,7 +293,7 @@ pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource const was_evaluating = self.is_evaluating; self.is_evaluating = true; - defer self.is_evaluating = was_evaluating; + defer self.endEvaluationWindow(was_evaluating); var client = self.client; while (true) { @@ -350,13 +356,12 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy } // It's possible, but unlikely, for client.request to immediately finish - // a request, thus calling our callback. We generally don't want a call - // from v8 (which is why we're here), to result in a new script evaluation. - // So we block even the slightest change that `client.request` immediately - // executes a callback. + // a request, thus calling our callback. We don't want that to trigger a + // script evaluation while we're still setting the request up (the script + // isn't fully linked into our lists yet). const was_evaluating = self.is_evaluating; self.is_evaluating = true; - defer self.is_evaluating = was_evaluating; + defer self.endEvaluationWindow(was_evaluating); const owner = self.owner; const session = self.owner.session(); @@ -405,62 +410,97 @@ pub fn scriptCreatedParseDone(self: *ScriptManagerBase) void { pub fn evaluate(self: *ScriptManagerBase) void { if (self.is_evaluating) { // It's possible for a script.eval to cause evaluate to be called again. + // Signal that this happened so that, once the outer evaluate finishes, + // we evaluate() again to catch what we're skipping now. + self.evaluate_pending = true; return; } self.is_evaluating = true; - defer self.is_evaluating = false; + defer self.endEvaluationWindow(false); - while (self.ready_scripts.popFirst()) |n| { - var script: *Script = @fieldParentPtr("node", n); - switch (script.extra) { - .frame => { - // Only .async mode reaches ready_scripts (defer stays in - // defer_scripts, normal is sync and never queued). - defer script.deinit(); - script.eval(); - }, - .import_async => |ia| { - if (script.status < 200 or script.status > 299) { + while (true) { + self.evaluate_pending = false; + + while (self.ready_scripts.popFirst()) |n| { + var script: *Script = @fieldParentPtr("node", n); + switch (script.extra) { + .frame => { + // Only .async mode reaches ready_scripts (defer stays in + // defer_scripts, normal is sync and never queued). + defer script.deinit(); + script.eval(); + }, + .import_async => |ia| { + if (script.status < 200 or script.status > 299) { + script.deinit(); + ia.callback(ia.data, error.FailedToLoad); + } else { + ia.callback(ia.data, .{ + .shared = false, + .script = script, + .buffer = script.source.remote, + }); + } + }, + .import => unreachable, // .import doesn't go through ready_scripts + .preload => unreachable, // .preload is buffered in the map, never queued + } + } + + if (self.static_scripts_done) { + while (self.defer_scripts.first) |n| { + var script: *Script = @fieldParentPtr("node", n); + if (script.complete == false) break; + defer { + _ = self.defer_scripts.popFirst(); script.deinit(); - ia.callback(ia.data, error.FailedToLoad); - } else { - ia.callback(ia.data, .{ - .shared = false, - .script = script, - .buffer = script.source.remote, - }); } - }, - .import => unreachable, // .import doesn't go through ready_scripts + // Only frame scripts populate defer_scripts. + script.eval(); + } } - } - if (self.static_scripts_done == false) { - // We can only execute deferred scripts if - // 1 - all the normal scripts are done - // 2 - we've finished parsing the HTML and at least queued all the scripts - // The last one isn't obvious, but it's possible for self.scripts to - // be empty not because we're done executing all the normal scripts - // but because we're done executing some (or maybe none), but we're still - // parsing the HTML. - return; - } - - while (self.defer_scripts.first) |n| { - var script: *Script = @fieldParentPtr("node", n); - if (script.complete == false) return; - defer { - _ = self.defer_scripts.popFirst(); - script.deinit(); + // A script.eval above may have pumped the http client (module + // imports, preloads); anything that completed during the pump had + // its evaluate() swallowed by the re-entrancy guard, so re-drain + // here rather than relying on a future doneCallback that may never + // come. + if (self.evaluate_pending) { + continue; } - // Only frame scripts populate defer_scripts. - script.eval(); + + if (self.static_scripts_done == false) { + // We can only execute deferred scripts if + // 1 - all the normal scripts are done + // 2 - we've finished parsing the HTML and at least queued all the scripts + // The last one isn't obvious, but it's possible for self.scripts to + // be empty not because we're done executing all the normal scripts + // but because we're done executing some (or maybe none), but we're still + // parsing the HTML. + return; + } + if (self.defer_scripts.first != null) { + // Head of the defer list is still in flight; its doneCallback + // re-enters evaluate. + return; + } + break; } // Frame wrapper uses this to fire documentIsLoaded and // scriptsCompletedLoading. Null for workers. - if (self.tail_hook) |hook| hook(self); + if (self.tail_hook) |hook| { + hook(self); + } +} + +pub fn endEvaluationWindow(self: *ScriptManagerBase, was_evaluating: bool) void { + self.is_evaluating = was_evaluating; + if (was_evaluating == false and self.evaluate_pending) { + // we have something to evaluate + self.evaluate(); + } } pub const Script = struct { @@ -505,6 +545,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.html b/src/browser/tests/element/html/script/preload.html new file mode 100644 index 000000000..26ea2a291 --- /dev/null +++ b/src/browser/tests/element/html/script/preload.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + 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'); diff --git a/src/browser/tests/element/html/template.html b/src/browser/tests/element/html/template.html index 0c1d46d2f..df6369e97 100644 --- a/src/browser/tests/element/html/template.html +++ b/src/browser/tests/element/html/template.html @@ -228,3 +228,25 @@ testing.expectEqual('

hello, world

', out.innerHTML); + + 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/tests/shadowroot/declarative.html b/src/browser/tests/shadowroot/declarative.html index 91371a17d..c68087034 100644 --- a/src/browser/tests/shadowroot/declarative.html +++ b/src/browser/tests/shadowroot/declarative.html @@ -122,3 +122,96 @@ testing.expectError('NotSupportedError', () => sd.attachShadow({ mode: 'open' })); } + + + + + + + + diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html index c0832f215..7dbf1fea8 100644 --- a/src/browser/tests/shadowroot/events.html +++ b/src/browser/tests/shadowroot/events.html @@ -281,3 +281,48 @@ host.remove(); } + + + + diff --git a/src/browser/tests/shadowroot/scoping.html b/src/browser/tests/shadowroot/scoping.html index 37caaeb12..5e4e00fa2 100644 --- a/src/browser/tests/shadowroot/scoping.html +++ b/src/browser/tests/shadowroot/scoping.html @@ -90,3 +90,19 @@ host.remove(); } + + diff --git a/src/browser/tests/window/report_error.html b/src/browser/tests/window/report_error.html index c2d66125a..298b2e6cd 100644 --- a/src/browser/tests/window/report_error.html +++ b/src/browser/tests/window/report_error.html @@ -119,7 +119,8 @@ window.reportError(err); testing.expectEqual(window, evt.target); - testing.expectEqual(window, evt.currentTarget); + // currentTarget is only set while listeners are invoked; null post-dispatch + testing.expectEqual(null, evt.currentTarget); } diff --git a/src/browser/webapi/DataTransfer.zig b/src/browser/webapi/DataTransfer.zig new file mode 100644 index 000000000..d53eb07d5 --- /dev/null +++ b/src/browser/webapi/DataTransfer.zig @@ -0,0 +1,289 @@ +// 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"; + } + return std.ascii.allocLowerString(arena, format); +} + +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 owned_data = 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 = owned_data }; + return; + } + } + const it = try self._arena.create(DataTransferItem); + it.* = .{ ._data_transfer = self, ._kind = .string, ._type = norm, ._payload = .{ .string = owned_data } }; + 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 owned_data = try data.toStringSliceWithAlloc(self._arena); + const norm = try normalizeFormat(self._arena, type_ orelse ""); + const it = try self._arena.create(DataTransferItem); + it.* = .{ ._data_transfer = self, ._kind = .string, ._type = norm, ._payload = .{ .string = owned_data } }; + 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.* = .{ ._data_transfer = self, ._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..a3c973fec --- /dev/null +++ b/src/browser/webapi/DataTransferItem.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 std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../js/js.zig"); + +const File = @import("File.zig"); +const Page = @import("../Page.zig"); +const DataTransfer = @import("DataTransfer.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 }; + +// The owning DataTransfer. The item lives in that DataTransfer's arena and is +// handed to JS, so it forwards acquireRef/releaseRef to keep the arena alive as +// long as JS holds the item. +_data_transfer: *DataTransfer, +_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 acquireRef(self: *DataTransferItem) void { + self._data_transfer.acquireRef(); +} + +pub fn releaseRef(self: *DataTransferItem, page: *Page) void { + self._data_transfer.releaseRef(page); +} + +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..92fb6c7b1 --- /dev/null +++ b/src/browser/webapi/DataTransferItemList.zig @@ -0,0 +1,121 @@ +// 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 Page = @import("../Page.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 acquireRef(self: *DataTransferItemList) void { + self._data_transfer.acquireRef(); +} + +pub fn releaseRef(self: *DataTransferItemList, page: *Page) void { + self._data_transfer.releaseRef(page); +} + +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 acquireRef(self: *@This()) void { + self.list.acquireRef(); + } + + pub fn releaseRef(self: *@This(), page: *Page) void { + self.list.releaseRef(page); + } + + 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; + } +}; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index bbc44a829..c276650ad 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -763,10 +763,7 @@ fn isValidShadowHost(self: *const Element) bool { }; } -pub fn attachShadow(self: *Element, mode_str: String, frame: *Frame) !*ShadowRoot { - if (frame._element_shadow_roots.get(self)) |_| { - return error.NotSupported; - } +pub fn attachShadow(self: *Element, opts: ShadowRoot.AttachOptions, frame: *Frame) !*ShadowRoot { if (!self.isValidShadowHost()) { return error.NotSupported; } @@ -780,13 +777,20 @@ pub fn attachShadow(self: *Element, mode_str: String, frame: *Frame) !*ShadowRoo } } } - const mode: ShadowRoot.Mode = blk: { - if (mode_str.eql(comptime .wrap("open"))) break :blk .open; - if (mode_str.eql(comptime .wrap("closed"))) break :blk .closed; - return error.InvalidArgument; - }; - const shadow_root = try ShadowRoot.init(self, mode, frame); + if (frame._element_shadow_roots.get(self)) |existing| { + // Imperative attachShadow over a declarative shadow root with a matching + // mode empties it and returns the same root. The parser + // (opts.declarative) never replaces an existing root. + if (opts.declarative or !existing._declarative or existing._mode != opts.mode) { + return error.NotSupported; + } + try existing.asNode().replaceChildren(&.{}, frame); + existing._declarative = false; + return existing; + } + + const shadow_root = try ShadowRoot.init(self, opts, frame); try frame._element_shadow_roots.put(frame.arena, self, shadow_root); return shadow_root; } @@ -1459,10 +1463,33 @@ pub fn clone(self: *Element, deep: bool, frame: *Frame) !*Node { const node = try frame.createElementNS(self._namespace, tag_name, self._attributes); // Allow element-specific types to copy their runtime state - _ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), frame }) catch |err| { + _ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), deep, frame }) catch |err| { log.err(.dom, "element.clone.failed", .{ .err = err }); }; + // Per spec, a clonable shadow root is cloned along with its host — its + // children always deep-cloned, even when the host clone is shallow. + if (frame._element_shadow_roots.get(self)) |shadow| { + if (shadow._clonable) { + const cloned_shadow = node.as(Element).attachShadow(.{ + .mode = shadow._mode, + .clonable = true, + .delegates_focus = shadow._delegates_focus, + .slot_assignment = shadow._slot_assignment, + .serializable = shadow._serializable, + .declarative = shadow._declarative, + }, frame) catch return error.CloneError; + + const cloned_shadow_node = cloned_shadow.asNode(); + var shadow_child_it = shadow.asNode().childrenIterator(); + while (shadow_child_it.next()) |child| { + if (try child.cloneNodeForAppending(true, frame)) |cloned_child| { + try frame.appendNode(cloned_shadow_node, cloned_child, .{ .child_already_connected = true }); + } + } + } + } + if (deep) { var child_it = self.asNode().childrenIterator(); while (child_it.next()) |child| { @@ -1915,9 +1942,30 @@ pub const JsApi = struct { const ShadowRootInit = struct { mode: String, + delegatesFocus: bool = false, + slotAssignment: ?String = null, + clonable: bool = false, + serializable: bool = false, }; fn _attachShadow(self: *Element, init: ShadowRootInit, frame: *Frame) !*ShadowRoot { - return self.attachShadow(init.mode, frame); + const mode: ShadowRoot.Mode = blk: { + if (init.mode.eql(comptime .wrap("open"))) break :blk .open; + if (init.mode.eql(comptime .wrap("closed"))) break :blk .closed; + return error.InvalidArgument; + }; + const slot_assignment: ShadowRoot.SlotAssignment = blk: { + const sa = init.slotAssignment orelse break :blk .named; + if (sa.eql(comptime .wrap("named"))) break :blk .named; + if (sa.eql(comptime .wrap("manual"))) break :blk .manual; + return error.InvalidArgument; + }; + return self.attachShadow(.{ + .mode = mode, + .delegates_focus = init.delegatesFocus, + .slot_assignment = slot_assignment, + .clonable = init.clonable, + .serializable = init.serializable, + }, frame); } pub const replaceChildren = bridge.function(Element.replaceChildren, .{ .dom_exception = true, .ce_reactions = true }); pub const replaceWith = bridge.function(Element.replaceWith, .{ .dom_exception = true, .ce_reactions = true }); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index d2b064736..fd878d287 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -330,15 +330,23 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget { node = n._parent; } - // Add window at the end (unless we stopped at shadow boundary) - if (!stopped_at_shadow_boundary) { - if (path_len < path_buffer.len) { - switch (exec.js.global) { - .worker => {}, - .frame => |frame| { - path_buffer[path_len] = frame.window.asEventTarget(); - path_len += 1; - }, + // Add window at the end. It only participates when propagation did not stop + // at a shadow boundary... + if (stopped_at_shadow_boundary == false) { + // ... AND when the tree's root is a document + 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) { + if (path_len < path_buffer.len) { + switch (exec.js.global) { + .worker => {}, + .frame => |frame| { + path_buffer[path_len] = frame.window.asEventTarget(); + path_len += 1; + }, + } } } } diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index f38eca3ca..b2e10843c 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -47,7 +47,8 @@ _root: ?*Element = null, _root_margin: []const u8 = "0px", _threshold: []const f64 = &.{0.0}, _pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{}, -_previous_states: std.AutoHashMapUnmanaged(*Element, bool) = .{}, +// tracked targets that aren't reported yet +_tracked: std.AutoHashMapUnmanaged(*Element, void) = .{}, // Shared zero DOMRect to avoid repeated allocations for non-intersecting elements var zero_rect: DOMRect = .{ @@ -140,8 +141,7 @@ pub fn observe(self: *IntersectionObserver, target: *Element, frame: *Frame) !vo try frame.registerIntersectionObserver(self); } - // Don't initialize previous state yet - let checkIntersection do it - // This ensures we get an entry on first observation + try self._tracked.put(self._arena, target, {}); // Check intersection for this new target and schedule delivery try self.checkIntersection(target, frame); @@ -155,7 +155,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) v for (self._observing.items, 0..) |elem, i| { if (elem == target) { _ = self._observing.swapRemove(i); - _ = self._previous_states.remove(target); + _ = self._tracked.remove(target); // Remove any pending entries for this target. // Entries will be cleaned up by V8 GC via the finalizer. @@ -182,7 +182,7 @@ pub fn disconnect(self: *IntersectionObserver, frame: *Frame) void { entry.deinit(frame._page); } self._pending_entries.clearRetainingCapacity(); - self._previous_states.clearRetainingCapacity(); + self._tracked.clearRetainingCapacity(); if (self._observing.items.len > 0) { frame.unregisterIntersectionObserver(self); @@ -199,6 +199,7 @@ pub fn takeRecords(self: *IntersectionObserver, frame: *Frame) ![]*IntersectionO fn calculateIntersection( self: *IntersectionObserver, target: *Element, + has_parent: bool, frame: *Frame, ) !IntersectionData { const target_rect = target.getBoundingClientRect(frame); @@ -219,15 +220,13 @@ fn calculateIntersection( // This avoids fingerprinting issues (massive viewports) and matches the behavior // scripts expect when querying element visibility. // However, elements without a parent cannot intersect (they have no containing block). - const has_parent = target.asNode().parentNode() != null; - const is_intersecting = has_parent; const intersection_ratio: f64 = if (has_parent) 1.0 else 0.0; // Intersection rect is the same as the target rect if visible, otherwise zero rect const intersection_rect = if (has_parent) target_rect else zero_rect; return .{ - .is_intersecting = is_intersecting, + .is_intersecting = has_parent, .intersection_ratio = intersection_ratio, .intersection_rect = intersection_rect, .bounding_client_rect = target_rect, @@ -253,37 +252,40 @@ fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool { } fn checkIntersection(self: *IntersectionObserver, target: *Element, frame: *Frame) !void { - const data = try self.calculateIntersection(target, frame); - const was_intersecting_opt = self._previous_states.get(target); - const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio); + const tracked = self._tracked.getEntry(target) orelse { + // If target isn't tracked then it was already reported. This is an + // important optimization which lets us skip the heavy work below. + // This function can be called a lot. + return; + }; - // Create entry if: - // 1. First time observing this target AND it's intersecting - // 2. State changed - const should_report = (was_intersecting_opt == null and is_now_intersecting) or - (was_intersecting_opt != null and was_intersecting_opt.? != is_now_intersecting); - - if (should_report) { - const arena = try frame.getArena(.tiny, "IntersectionObserverEntry"); - errdefer frame.releaseArena(arena); - - const entry = try arena.create(IntersectionObserverEntry); - entry.* = .{ - ._arena = arena, - ._target = target, - ._time = frame.window._performance.now(), - ._is_intersecting = is_now_intersecting, - ._root_bounds = try frame._factory.create(data.root_bounds), - ._intersection_rect = try frame._factory.create(data.intersection_rect), - ._bounding_client_rect = try frame._factory.create(data.bounding_client_rect), - ._intersection_ratio = data.intersection_ratio, - }; - try self._pending_entries.append(self._arena, entry); + const has_parent = target.asNode().parentNode() != null; + const is_now_intersecting = has_parent and self.meetsThreshold(1.0); + if (!is_now_intersecting) { + // Not intersecting yet (e.g. observed while still orphaned) — keep + // tracking so a later attach is reported. + return; } - // Always update the previous state, even if we didn't report - // This ensures we can detect state changes on subsequent checks - try self._previous_states.put(self._arena, target, is_now_intersecting); + // Building the entry is the expensive part — getBoundingClientRect walks the + // document to fake a position (O(node count)) — so it only runs here. + const data = try self.calculateIntersection(target, has_parent, frame); + const arena = try frame.getArena(.tiny, "IntersectionObserverEntry"); + errdefer frame.releaseArena(arena); + + const entry = try arena.create(IntersectionObserverEntry); + entry.* = .{ + ._arena = arena, + ._target = target, + ._time = frame.window._performance.now(), + ._is_intersecting = is_now_intersecting, + ._root_bounds = try frame._factory.create(data.root_bounds), + ._intersection_rect = try frame._factory.create(data.intersection_rect), + ._bounding_client_rect = try frame._factory.create(data.bounding_client_rect), + ._intersection_ratio = data.intersection_ratio, + }; + try self._pending_entries.append(self._arena, entry); + _ = self._tracked.removeByPtr(tracked.key_ptr); } pub fn checkIntersections(self: *IntersectionObserver, frame: *Frame) !void { diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index f7cb66882..8a1580648 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -479,7 +479,7 @@ pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node { // If composed is true, traverse through shadow boundaries if (opts.composed) { while (true) { - const shadow_root = @constCast(root).is(ShadowRoot) orelse break; + const shadow_root = root.is(ShadowRoot) orelse break; root = shadow_root.getHost().asNode(); while (root._parent) |parent| { root = parent; @@ -525,6 +525,16 @@ pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document { return current._type.document; } + // A shadow tree's root is a parent-less ShadowRoot fragment; its owner + // is the host's owner document. + // can't use current.is(ShadowRoot) without @constCast on `current` + if (current._type == .document_fragment) { + const df = current._type.document_fragment; + if (df._type == .shadow_root) { + return df._type.shadow_root._host.asNode().ownerDocument(frame); + } + } + // Otherwise, this is a detached node. Check if it has a specific owner // document registered (for nodes created via non-main documents). if (frame._node_owner_documents.get(@constCast(self))) |owner| { diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig index 53e19289c..9c7fb94ff 100644 --- a/src/browser/webapi/ShadowRoot.zig +++ b/src/browser/webapi/ShadowRoot.zig @@ -31,18 +31,42 @@ pub const Mode = enum { closed, }; +pub const SlotAssignment = enum { + named, + manual, +}; + +pub const AttachOptions = struct { + mode: Mode, + delegates_focus: bool = false, + slot_assignment: SlotAssignment = .named, + clonable: bool = false, + serializable: bool = false, + declarative: bool = false, +}; + _proto: *DocumentFragment, _mode: Mode, _host: *Element, +_delegates_focus: bool, +_slot_assignment: SlotAssignment, +_clonable: bool, +_serializable: bool, +_declarative: bool, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .{}, _removed_ids: std.StringHashMapUnmanaged(void) = .{}, _adopted_style_sheets: ?js.Object.Global = null, -pub fn init(host: *Element, mode: Mode, frame: *Frame) !*ShadowRoot { +pub fn init(host: *Element, opts: AttachOptions, frame: *Frame) !*ShadowRoot { return frame._factory.documentFragment(ShadowRoot{ ._proto = undefined, - ._mode = mode, + ._mode = opts.mode, ._host = host, + ._delegates_focus = opts.delegates_focus, + ._slot_assignment = opts.slot_assignment, + ._clonable = opts.clonable, + ._serializable = opts.serializable, + ._declarative = opts.declarative, }); } @@ -66,6 +90,22 @@ pub fn getHost(self: *const ShadowRoot) *Element { return self._host; } +pub fn getDelegatesFocus(self: *const ShadowRoot) bool { + return self._delegates_focus; +} + +pub fn getSlotAssignment(self: *const ShadowRoot) []const u8 { + return @tagName(self._slot_assignment); +} + +pub fn getClonable(self: *const ShadowRoot) bool { + return self._clonable; +} + +pub fn getSerializable(self: *const ShadowRoot) bool { + return self._serializable; +} + pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void { return self.asDocumentFragment().setHTMLUnsafe(html, frame); } @@ -125,6 +165,10 @@ pub const JsApi = struct { pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{}); pub const host = bridge.accessor(ShadowRoot.getHost, null, .{}); + pub const delegatesFocus = bridge.accessor(ShadowRoot.getDelegatesFocus, null, .{}); + pub const slotAssignment = bridge.accessor(ShadowRoot.getSlotAssignment, null, .{}); + pub const clonable = bridge.accessor(ShadowRoot.getClonable, null, .{}); + pub const serializable = bridge.accessor(ShadowRoot.getSerializable, null, .{}); pub const getElementById = bridge.function(_getElementById, .{}); fn _getElementById(self: *ShadowRoot, value_: ?js.Value, frame: *Frame) !?*Element { const value = value_ orelse return null; diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index d3bb146ae..d624254d5 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -1525,7 +1525,8 @@ pub const Build = struct { } } - pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void { + pub fn cloned(source_element: *Element, cloned_element: *Element, deep: bool, _: *Frame) !void { + _ = deep; const source = source_element.as(Input); const clone = cloned_element.as(Input); diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 49ab7a6d5..999bac1a1 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -195,21 +195,13 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { const element = self.asElement(); - const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return; - const loadable_rels = std.StaticStringMap(void).initComptime(.{ - .{ "stylesheet", {} }, - .{ "preload", {} }, - .{ "modulepreload", {} }, - }); - if (loadable_rels.has(rel) == false) { - return; - } - const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; if (href.len == 0) { return; } + const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return; + // Opt-in fetch for `rel="stylesheet"` — drives `frame.loadExternalStylesheet`, // which fires the load/error event itself. Other rels (preload, // modulepreload) and the disabled case keep the rendering-free stub that @@ -218,6 +210,28 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { return frame.loadExternalStylesheet(self, href); } + var queue_load = false; + if (std.mem.eql(u8, rel, "preload")) { + const as = element.getAttributeSafe(comptime .wrap("as")) orelse ""; + if (std.ascii.eqlIgnoreCase(as, "script")) { + frame.preloadScriptHint(href); + } + queue_load = true; + } + + { + // this block just means we don't need to re-check rel for a type we + // already processed, e.g. "preload" + const loadable_rels = std.StaticStringMap(void).initComptime(.{ + .{ "stylesheet", {} }, + .{ "modulepreload", {} }, + }); + + if (queue_load == false and loadable_rels.has(rel) == false) { + return; + } + } + try frame.queueLoad(self._proto); } diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 0522feafc..41e440947 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -154,7 +154,8 @@ pub const Build = struct { // Per the HTML spec, the "already started" flag must be propagated to the // clone so that re-inserting a cloned