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