Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-05-06 07:56:28 +02:00
30 changed files with 608 additions and 82 deletions

View File

@@ -100,7 +100,10 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi
pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
var url_buf: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&url_buf);
const unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
var unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
if (std.mem.indexOfScalarPos(u8, unescaped_file_path, 0, '?')) |pos| {
unescaped_file_path = unescaped_file_path[0..pos];
}
var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) {
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
else => return err,
@@ -114,7 +117,7 @@ pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
.content_length = stat.size,
.respond_options = .{
.extra_headers = &.{
.{ .name = "content-type", .value = getContentType(file_path) },
.{ .name = "content-type", .value = getContentType(unescaped_file_path) },
},
},
});

View File

@@ -86,7 +86,6 @@ pub fn closeSession(self: *Browser) void {
if (self.session) |*session| {
session.deinit();
self.session = null;
self.env.memoryPressureNotification(.critical);
}
}

View File

@@ -266,7 +266,11 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
was_handled = true;
event._current_target = target_et;
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
// Inline handlers (e.g. onclick property) follow the same "report,
// don't propagate" rule as addEventListener listeners — see Listener.run.
ls.toLocal(inline_handler).callWithThis(void, target_et, .{event}) catch |err| {
log.warn(.event, "inline handler", .{ .err = err });
};
if (event._stop_propagation) {
return;
@@ -388,19 +392,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = getAdjustedTarget(original_target, current_target);
}
switch (listener.function) {
.value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try frame.call_arena.dupeZ(u8, string.str());
try local.eval(str, null);
},
.object => |obj_global| {
const obj = local.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
},
}
try listener.run(frame.call_arena, local, event, "listener");
// Restore original target (only if we changed it)
if (event._needs_retargeting) {

View File

@@ -305,19 +305,7 @@ pub fn dispatchDirect(
event._current_target = target;
switch (listener.function) {
.value => |value| try ls.local.toLocal(value).callWithThis(void, target, .{event}),
.string => |string| {
const str = try arena.dupeZ(u8, string.str());
try ls.local.eval(str, null);
},
.object => |obj_global| {
const obj = ls.local.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
},
}
try listener.run(arena, &ls.local, event, opts.context);
if (event._stop_immediate_propagation) {
return;
@@ -392,6 +380,46 @@ pub const Listener = struct {
signal: ?*@import("webapi/AbortSignal.zig") = null,
node: std.DoublyLinkedList.Node,
removed: bool = false,
// Per DOM §2.9 step 4 substep 8 ("Inner invoke"), a listener callback that
// throws must have its exception *reported* to the global error handler,
// not propagated to the dispatch caller — subsequent listeners on the same
// target and the rest of the propagation path must still run.
//
// Caller must set `event._current_target` before invoking — the function-
// listener variant uses it as `this`, matching the spec contract that a
// listener sees its current target via both `event.currentTarget` and `this`.
pub fn run(
self: *const Listener,
arena: Allocator,
local: *const js.Local,
event: *Event,
comptime context: []const u8,
) error{OutOfMemory}!void {
switch (self.function) {
.value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| {
log.warn(.event, context, .{ .err = err });
},
.string => |string| {
const str = try arena.dupeZ(u8, string.str());
local.eval(str, null) catch |err| {
log.warn(.event, context, .{ .err = err });
};
},
.object => |obj_global| {
const obj = local.toLocal(obj_global);
const handle_event = obj.getFunction("handleEvent") catch |err| blk: {
log.warn(.event, context, .{ .err = err });
break :blk null;
};
if (handle_event) |handleEvent| {
handleEvent.callWithThis(void, obj, .{event}) catch |err| {
log.warn(.event, context, .{ .err = err });
};
}
},
}
}
};
pub const Function = union(enum) {

View File

@@ -3007,7 +3007,7 @@ pub fn appendNode(self: *Frame, parent: *Node, child: *Node, opts: InsertNodeOpt
}
pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
self.domChanged();
target.bumpDomVersion(self);
const dest_connected = target.isConnected();
// Use firstChild() instead of iterator to handle cases where callbacks
@@ -3022,7 +3022,7 @@ pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
}
pub fn insertAllChildrenBefore(self: *Frame, fragment: *Node, parent: *Node, ref_node: *Node) !void {
self.domChanged();
parent.bumpDomVersion(self);
const dest_connected = parent.isConnected();
// Use firstChild() instead of iterator to handle cases where callbacks

View File

@@ -105,6 +105,12 @@ pub fn deinit(self: *Session) void {
self.removePage();
}
self.cookie_jar.deinit();
// Force V8 to flush any remaining weak callbacks while
// fc_identity_pool is still alive. Identity structs allocated from
// this pool back V8 weak-callback parameters; freeing the pool first
// would leave dangling pointers that segfault on the next GC.
self.browser.env.memoryPressureNotification(.critical);
self.fc_identity_pool.deinit();
self.storage_shed.deinit(self.browser.app.allocator);

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<head></head>
<body></body>
<script src="../../../testing.js"></script>
<!--
Per the HTML spec, the "already started" flag must be propagated when a script
element is cloned. A clone of a script that has already run must NOT run again
when inserted into a document. This guards a regression where cloneNode lost
the flag and re-ran every cloned <script> on each insertion.
-->
<script id=cloned_inline_does_not_rerun>
window.cloned_inline_count = 0;
const c1_original = document.createElement('script');
c1_original.textContent = 'window.cloned_inline_count++;';
document.head.appendChild(c1_original);
testing.expectEqual(1, window.cloned_inline_count);
// Clone the executed script and insert the clone. The clone must not re-run.
const c1_clone = c1_original.cloneNode(true);
document.head.appendChild(c1_clone);
testing.expectEqual(1, window.cloned_inline_count);
// A deep clone of a wrapper containing the script must keep the flag too.
const c1_wrapper = document.createElement('div');
c1_wrapper.appendChild(c1_original.cloneNode(true));
document.body.appendChild(c1_wrapper);
testing.expectEqual(1, window.cloned_inline_count);
</script>

View File

@@ -762,3 +762,95 @@
testing.expectEqual(2, calls.length);
}
</script>
<div id=throw_host><div id=throw_mid><span id=throw_leaf></span></div></div>
<script id=listenerThrowDoesNotHaltDispatch>
// Per DOM §2.9 step 4 substep 8 "Inner invoke", a listener callback that
// throws must have its exception reported, not propagated — subsequent
// listeners on the same target and the rest of the propagation path must
// still run.
{
const leaf = $('#throw_leaf');
const mid = $('#throw_mid');
const host = $('#throw_host');
const hits = [];
leaf.addEventListener('throw_test', () => {
hits.push('leaf-throwing');
throw new Error('listener-throw probe');
});
leaf.addEventListener('throw_test', () => hits.push('leaf-sibling'));
mid.addEventListener ('throw_test', () => hits.push('mid'));
host.addEventListener('throw_test', () => hits.push('host'));
document.body.addEventListener('throw_test', () => hits.push('body'));
// dispatchEvent must NOT throw to the caller — if it did, this assignment
// would be skipped and the next testing.expectEqual would never run.
const result = leaf.dispatchEvent(new Event('throw_test', {bubbles: true}));
testing.expectEqual('leaf-throwing', hits[0]);
testing.expectEqual('leaf-sibling', hits[1]);
testing.expectEqual('mid', hits[2]);
testing.expectEqual('host', hits[3]);
testing.expectEqual('body', hits[4]);
testing.expectEqual(5, hits.length);
testing.expectEqual(true, result);
}
</script>
<div id=throw_capture_host><div id=throw_capture_mid><span id=throw_capture_leaf></span></div></div>
<script id=listenerThrowDuringCapturePhase>
// Capture-phase variant: a throwing capture listener on an ancestor must
// not skip the rest of the capture walk, the at-target listener, or the
// bubble walk.
{
const leaf = $('#throw_capture_leaf');
const mid = $('#throw_capture_mid');
const host = $('#throw_capture_host');
const order = [];
host.addEventListener('cap_test', () => {
order.push('host-capture-throwing');
throw new Error('capture-throw probe');
}, true);
mid.addEventListener('cap_test', () => order.push('mid-capture'), true);
leaf.addEventListener('cap_test', () => order.push('leaf-target'));
mid.addEventListener('cap_test', () => order.push('mid-bubble'));
host.addEventListener('cap_test', () => order.push('host-bubble'));
leaf.dispatchEvent(new Event('cap_test', {bubbles: true}));
testing.expectEqual('host-capture-throwing', order[0]);
testing.expectEqual('mid-capture', order[1]);
testing.expectEqual('leaf-target', order[2]);
testing.expectEqual('mid-bubble', order[3]);
testing.expectEqual('host-bubble', order[4]);
testing.expectEqual(5, order.length);
}
</script>
<script id=listenerThrowOnWindowTarget>
// Direct-dispatch variant (non-DOM target — Window). Same contract: a
// throwing listener must not stop later listeners from running.
{
const calls = [];
const a = () => { calls.push('a-throwing'); throw new Error('window-throw probe'); };
const b = () => calls.push('b');
const c = () => calls.push('c');
window.addEventListener('win_throw', a);
window.addEventListener('win_throw', b);
window.addEventListener('win_throw', c);
window.dispatchEvent(new Event('win_throw'));
testing.expectEqual('a-throwing', calls[0]);
testing.expectEqual('b', calls[1]);
testing.expectEqual('c', calls[2]);
testing.expectEqual(3, calls.length);
window.removeEventListener('win_throw', a);
window.removeEventListener('win_throw', b);
window.removeEventListener('win_throw', c);
}
</script>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<head></head>
<body>
<script src="../testing.js"></script>
<!--
Live collections (NodeList from .childNodes, HTMLCollection, NodeLive variants
like getElementsByTagName) cache state and invalidate on the owning frame's
DOM version. Cross-realm callers — parent JS reading `iframe.x.childNodes` —
must still see mutations applied through the iframe's frame, even though the
caller's own frame.version doesn't bump on those mutations.
This guards a regression where the cache was tied to the calling frame and
returned stale results after iframe-side mutations.
-->
<iframe id=if_collection src="support/cross_realm_collection.html"></iframe>
<script id=childNodes_invalidates_after_iframe_mutation>
testing.onload(() => {
const idoc = document.getElementById('if_collection').contentDocument;
const iwin = document.getElementById('if_collection').contentWindow;
const testDiv = idoc.querySelector('#test');
const p0 = idoc.querySelector('#p0');
const p1 = idoc.querySelector('#p1');
// Prime the parent-realm view of the live NodeList.
const cn = testDiv.childNodes;
testing.expectEqual(3, cn.length);
testing.expectEqual(p0, cn[0]);
// Mutate the iframe's DOM through parent-realm code. The mutation only
// bumps the iframe frame's version; the parent's stays unchanged. The
// cached collection must still reflect the new state.
testDiv.removeChild(p0);
testing.expectEqual(2, cn.length);
testing.expectEqual(p1, cn[0]);
testing.expectEqual(undefined, cn[2]);
});
</script>
<script id=getElementsByTagName_invalidates_after_iframe_mutation>
testing.onload(() => {
const idoc = document.getElementById('if_collection').contentDocument;
const ps = idoc.getElementsByTagName('p');
const seen_initial = ps.length;
// Append a new <p> through the iframe's document.
const fresh = idoc.createElement('p');
fresh.id = 'pfresh';
idoc.querySelector('#test').appendChild(fresh);
testing.expectEqual(seen_initial + 1, ps.length);
testing.expectEqual(fresh, ps[ps.length - 1]);
});
</script>
</body>

View File

@@ -0,0 +1,3 @@
<!DOCTYPE html>
<head></head>
<body><div id=test><p id=p0>p0</p><p id=p1>p1</p><p id=p2>p2</p></div></body>

View File

@@ -65,3 +65,32 @@
assertChildren(['a'], d3);
testing.expectEqual(null, b.parentNode);
</script>
<div id=d4></div>
<div id=d4_stash></div>
<script id=appendChild_disconnect_callback_reparents>
// Moving a connected child into a disconnected target makes
// will_be_reconnected=false, so disconnectedCallback fires synchronously
// inside removeNode. The callback re-parents the child; appendChild
// respects that placement instead of overriding it.
const d4 = $('#d4');
const stash = $('#d4_stash');
class ReparentOnDisconnect extends HTMLElement {
disconnectedCallback() {
if (this.parentNode === null) {
stash.appendChild(this);
}
}
}
customElements.define('reparent-on-disconnect', ReparentOnDisconnect);
const rpd = document.createElement('reparent-on-disconnect');
rpd.id = 'rpd';
d4.appendChild(rpd);
const detached = document.createElement('div');
detached.appendChild(rpd);
testing.expectEqual(stash, rpd.parentNode);
</script>

View File

@@ -39,3 +39,34 @@
assertChildren([], d1);
assertChildren([c1, c2], d2);
</script>
<div id=d3></div>
<div id=d3_stash></div>
<script id=insertBefore_disconnect_callback_reparents>
// Same disconnectedCallback re-parenting pattern as in append_child.html,
// exercised through insertBefore. insertBefore respects the callback's
// placement instead of overriding it.
const d3 = $('#d3');
const stash = $('#d3_stash');
class IBReparentOnDisconnect extends HTMLElement {
disconnectedCallback() {
if (this.parentNode === null) {
stash.appendChild(this);
}
}
}
customElements.define('ib-reparent-on-disconnect', IBReparentOnDisconnect);
const moving = document.createElement('ib-reparent-on-disconnect');
moving.id = 'ib_moving';
d3.appendChild(moving);
const detached = document.createElement('div');
const ref = document.createElement('span');
detached.appendChild(ref);
detached.insertBefore(moving, ref);
testing.expectEqual(stash, moving.parentNode);
</script>

View File

@@ -42,4 +42,4 @@
</script>
<!-- Leave it, it used to crash -->
<script src='empty.js=["violated-directive=worker-src","TEST COMPLETE"]'></script>
<script src='empty.js?x=["violated-directive=worker-src","TEST COMPLETE"]'></script>

View File

@@ -488,8 +488,8 @@ pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, frame: *Frame)
pub fn append(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !void {
try validateDocumentNodes(self, nodes, false);
frame.domChanged();
const parent = self.asNode();
parent.bumpDomVersion(frame);
const parent_is_connected = parent.isConnected();
for (nodes) |node_or_text| {
@@ -513,8 +513,8 @@ pub fn append(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !v
pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !void {
try validateDocumentNodes(self, nodes, false);
frame.domChanged();
const parent = self.asNode();
parent.bumpDomVersion(frame);
const parent_is_connected = parent.isConnected();
var i = nodes.len;
@@ -736,7 +736,7 @@ fn writeInternal(self: *Document, text: []const []const u8, append_newline: bool
insert_after = child;
}
frame.domChanged();
parent.bumpDomVersion(frame);
self._write_insertion_point = children_to_insert.getLast();
}

View File

@@ -154,7 +154,7 @@ pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, frame: *Fra
pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
frame.domChanged();
parent.bumpDomVersion(frame);
var it = parent.childrenIterator();
while (it.next()) |child| {
frame.removeNode(parent, child, .{ .will_be_reconnected = false });

View File

@@ -467,7 +467,7 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
const node = self.asNode();
const parent = node._parent orelse return;
frame.domChanged();
parent.bumpDomVersion(frame);
if (html.len > 0) {
const fragment = (try Node.DocumentFragment.init(frame)).asNode();
try frame.parseHtmlAsChildren(fragment, html);
@@ -493,7 +493,7 @@ pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
// fires disconnectedCallback for custom elements, which can mutate
// the child list and dangle any cached next-pointer the iterator
// would otherwise hold.
frame.domChanged();
parent.bumpDomVersion(frame);
while (parent.firstChild()) |child| {
frame.removeNode(parent, child, .{ .will_be_reconnected = false });
}
@@ -879,10 +879,9 @@ pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, frame: *F
}
pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
frame.domChanged();
const ref_node = self.asNode();
const parent = ref_node._parent orelse return;
parent.bumpDomVersion(frame);
const parent_is_connected = parent.isConnected();
@@ -919,9 +918,9 @@ pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame
}
pub fn remove(self: *Element, frame: *Frame) void {
frame.domChanged();
const node = self.asNode();
const parent = node._parent orelse return;
parent.bumpDomVersion(frame);
frame.removeNode(parent, node, .{ .will_be_reconnected = false });
}

View File

@@ -226,7 +226,7 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node {
try validateNodeInsertion(self, child);
frame.domChanged();
self.bumpDomVersion(frame);
// If the child is currently connected, and if its new parent is connected,
// then we can remove + add a bit more efficiently (we don't have to fully
@@ -253,6 +253,11 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node {
try frame.adoptNodeTree(child, child_owner.?, parent_owner);
}
// A custom element callback can re-parent the node. If it does, we're done
if (child._parent != null) {
return child;
}
try frame.appendNode(self, child, .{
.child_already_connected = child_connected,
.adopting_to_new_document = adopting_to_new_document,
@@ -512,11 +517,30 @@ pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document {
return frame.document;
}
// Returns the Frame that owns this node's tree. Used to tie cached state of
// "live" collections (NodeList, HTMLCollection, etc.) to the right frame's DOM
// version: cross-realm callers must invalidate based on mutations through the
// node's owning frame, not the caller's frame.
//
// Falls back to `default` when the node has no associated document yet (e.g.,
// freshly created and detached) or its document has no frame.
pub fn ownerFrame(self: *const Node, default: *Frame) *Frame {
if (self._type == .document) {
return self._type.document._frame orelse default;
}
const doc = self.ownerDocument(default) orelse return default;
return doc._frame orelse default;
}
// Tells the owning frame that this node's subtree has changed. Use this from
// mutation paths instead of `frame.domChanged()` so cross-realm mutations
// (e.g., parent JS mutating an iframe's DOM) bump the iframe's version, not
// the caller's. Otherwise, live collections that key off the owning frame's
// version won't see the change and will return stale cached state.
pub fn bumpDomVersion(self: *const Node, default: *Frame) void {
self.ownerFrame(default).domChanged();
}
pub const ResolveURLOpts = struct {
allocator: ?Allocator = null,
};
@@ -548,7 +572,7 @@ pub fn removeChild(self: *Node, child: *Node, frame: *Frame) !*Node {
var it = self.childrenIterator();
while (it.next()) |n| {
if (n == child) {
frame.domChanged();
self.bumpDomVersion(frame);
frame.removeNode(self, child, .{ .will_be_reconnected = false });
return child;
}
@@ -563,7 +587,7 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
// special case: if nodes are the same, ignore the change.
if (new_node == ref_node_) {
frame.domChanged();
self.bumpDomVersion(frame);
if (frame.hasMutationObservers()) {
const parent = new_node._parent.?;
@@ -594,7 +618,7 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
const parent_owner = self.ownerDocument(frame) orelse self.as(Document);
const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;
frame.domChanged();
self.bumpDomVersion(frame);
const will_be_reconnected = self.isConnected() and !adopting_to_new_document;
if (new_node._parent) |parent| {
frame.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected });
@@ -605,6 +629,22 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
try frame.adoptNodeTree(new_node, child_owner.?, parent_owner);
}
// See Node.appendChild: a callback above (disconnectedCallback or
// adoptedCallback) can re-parent new_node. Let that placement stand.
if (new_node._parent != null) {
return new_node;
}
// The same callback could also have detached ref_node from self. Fall
// back to append so new_node still lands in self.
if (ref_node._parent != self) {
try frame.appendNode(self, new_node, .{
.child_already_connected = child_already_connected,
.adopting_to_new_document = adopting_to_new_document,
});
return new_node;
}
try frame.insertNodeRelative(
self,
new_node,
@@ -1049,7 +1089,7 @@ pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !v
}
}
frame.domChanged();
self.bumpDomVersion(frame);
// Remove all existing children
var it = self.childrenIterator();

View File

@@ -374,7 +374,7 @@ pub fn deleteContents(self: *Range, frame: *Frame) !void {
if (self._proto.getCollapsed()) {
return;
}
frame.domChanged();
self._proto._start_container.bumpDomVersion(frame);
// Simple case: same container
if (self._proto._start_container == self._proto._end_container) {

View File

@@ -34,8 +34,11 @@ _arena: std.mem.Allocator,
_last_index: usize,
_last_length: ?u32,
_last_node: ?*std.DoublyLinkedList.Node,
// Version observed on `_owning_frame` the last time we refreshed our cache.
// Compare against `_owning_frame.version` to detect DOM mutations.
_cached_version: usize,
_node: *Node,
_owning_frame: *Frame,
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
@@ -45,6 +48,8 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes {
const arena = try frame.getArena(.small, "ChildNodes");
errdefer frame.releaseArena(arena);
const owning_frame = node.ownerFrame(frame);
const self = try arena.create(ChildNodes);
self.* = .{
._node = node,
@@ -52,7 +57,8 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes {
._last_index = 0,
._last_node = null,
._last_length = null,
._cached_version = frame.version,
._cached_version = owning_frame.version,
._owning_frame = owning_frame,
};
return self;
}
@@ -61,8 +67,8 @@ pub fn deinit(self: *const ChildNodes, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn length(self: *ChildNodes, frame: *Frame) !u32 {
if (self.versionCheck(frame)) {
pub fn length(self: *ChildNodes, _: *Frame) !u32 {
if (self.versionCheck()) {
if (self._last_length) |cached_length| {
return cached_length;
}
@@ -75,16 +81,16 @@ pub fn length(self: *ChildNodes, frame: *Frame) !u32 {
return len;
}
pub fn getAtIndex(self: *ChildNodes, index: usize, frame: *Frame) !?*Node {
_ = self.versionCheck(frame);
pub fn getAtIndex(self: *ChildNodes, index: usize, _: *Frame) !?*Node {
_ = self.versionCheck();
var current = self._last_index;
var node: ?*std.DoublyLinkedList.Node = null;
if (index < current) {
if (index < current or self._last_node == null) {
current = 0;
node = self.first() orelse return null;
} else {
node = self._last_node orelse self.first() orelse return null;
node = self._last_node;
}
defer self._last_index = current;
@@ -116,8 +122,8 @@ pub fn entries(self: *ChildNodes, frame: *Frame) !*EntryIterator {
return .init(.{ .list = self }, frame);
}
fn versionCheck(self: *ChildNodes, frame: *Frame) bool {
const current = frame.version;
fn versionCheck(self: *ChildNodes) bool {
const current = self._owning_frame.version;
if (current == self._cached_version) {
return true;
}

View File

@@ -32,19 +32,23 @@ _tw: TreeWalker.FullExcludeSelf,
_last_index: usize,
_last_length: ?u32,
_cached_version: usize,
_owning_frame: *Frame,
pub fn init(root: *Node, frame: *Frame) HTMLAllCollection {
const owning_frame = root.ownerFrame(frame);
return .{
._last_index = 0,
._last_length = null,
._tw = TreeWalker.FullExcludeSelf.init(root, .{}),
._cached_version = frame.version,
._cached_version = owning_frame.version,
._owning_frame = owning_frame,
};
}
fn versionCheck(self: *HTMLAllCollection, frame: *const Frame) bool {
if (self._cached_version != frame.version) {
self._cached_version = frame.version;
fn versionCheck(self: *HTMLAllCollection, _: *const Frame) bool {
const current = self._owning_frame.version;
if (self._cached_version != current) {
self._cached_version = current;
self._last_index = 0;
self._last_length = null;
self._tw.reset();

View File

@@ -99,16 +99,19 @@ pub fn NodeLive(comptime mode: Mode) type {
_last_index: usize,
_last_length: ?u32,
_cached_version: usize,
_owning_frame: *Frame,
const Self = @This();
pub fn init(root: *Node, filter: Filter, frame: *Frame) Self {
const owning_frame = root.ownerFrame(frame);
return .{
._last_index = 0,
._last_length = null,
._filter = filter,
._tw = TW.init(root, .{}),
._cached_version = frame.version,
._cached_version = owning_frame.version,
._owning_frame = owning_frame,
};
}
@@ -342,8 +345,8 @@ pub fn NodeLive(comptime mode: Mode) type {
};
}
fn versionCheck(self: *Self, frame: *const Frame) bool {
const current = frame.version;
fn versionCheck(self: *Self, _: *const Frame) bool {
const current = self._owning_frame.version;
if (current == self._cached_version) {
return true;
}

View File

@@ -229,7 +229,7 @@ pub const List = struct {
};
try frame.addElementId(parent, element, entry._value.str());
}
frame.domChanged();
element.asNode().bumpDomVersion(frame);
frame.attributeChange(element, result.normalized, entry._value, old_value);
return entry;
}
@@ -292,7 +292,7 @@ pub const List = struct {
frame.removeElementId(element, entry._value.str());
}
frame.domChanged();
element.asNode().bumpDomVersion(frame);
frame.attributeRemove(element, result.normalized, old_value);
_ = frame._attribute_lookup.remove(@intFromPtr(entry));
self._list.remove(&entry._node);

View File

@@ -273,7 +273,7 @@ pub fn setInnerText(self: *HtmlElement, text: []const u8, frame: *Frame) !void {
const parent = self.asElement().asNode();
// Remove all existing children
frame.domChanged();
parent.bumpDomVersion(frame);
var it = parent.childrenIterator();
while (it.next()) |child| {
frame.removeNode(parent, child, .{ .will_be_reconnected = false });

View File

@@ -80,7 +80,7 @@ pub fn setSelected(self: *Option, selected: bool, frame: *Frame) !void {
// TODO: When setting selected=true, may need to unselect other options
// in the parent <select> if it doesn't have multiple attribute
self._selected = selected;
frame.domChanged();
self.asElement().asNode().bumpDomVersion(frame);
}
pub fn getDefaultSelected(self: *const Option) bool {

View File

@@ -151,6 +151,15 @@ pub const Build = struct {
const element = self.asElement();
self._src = element.getAttributeSafe(comptime .wrap("src")) orelse "";
}
// Per the HTML spec, the "already started" flag must be propagated to the
// clone so that re-inserting a cloned <script> doesn't run it again.
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
const source = source_element.as(Script);
const clone = cloned_element.as(Script);
clone._executed = source._executed;
clone._force_async = source._force_async;
}
};
const testing = @import("../../../../testing.zig");

View File

@@ -249,10 +249,17 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
var response = self._response;
response._http_response = null;
// Capture this before we reject. Rejection could trigger httpShutdownCallback
// (via a microtask callback). But if we're here, then we'll take care of
// cleaning up when we're done.
const owns_response = self._owns_response;
self._owns_response = false;
// the response is only passed on v8 on success, if we're here, it's safe to
// clear this. (defer since `self is in the response's arena).
defer if (self._owns_response) {
defer if (owns_response) {
response.deinit(self._exec.context.page);
};
@@ -266,10 +273,6 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *Fetch = @ptrCast(@alignCast(ctx));
if (comptime IS_DEBUG) {
// should always be true
std.debug.assert(self._owns_response);
}
if (self._owns_response) {
var response = self._response;

View File

@@ -22,10 +22,12 @@ const js = @import("../../js/js.zig");
const http = @import("../../../network/http.zig");
const URL = @import("../URL.zig");
const Headers = @import("Headers.zig");
const Blob = @import("../Blob.zig");
const AbortSignal = @import("../AbortSignal.zig");
const Headers = @import("Headers.zig");
const BodyInit = @import("body_init.zig").BodyInit;
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
@@ -48,7 +50,7 @@ pub const Input = union(enum) {
pub const InitOpts = struct {
method: ?[]const u8 = null,
headers: ?Headers.InitOpts = null,
body: ?[]const u8 = null,
body: ?BodyInit = null,
cache: Cache = .default,
credentials: Credentials = .@"same-origin",
signal: ?*AbortSignal = null,
@@ -86,7 +88,7 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request {
.request => |r| r._method,
};
const headers = if (opts.headers) |headers_init| switch (headers_init) {
var headers = if (opts.headers) |headers_init| switch (headers_init) {
.obj => |h| h,
else => try Headers.init(headers_init, exec),
} else switch (input) {
@@ -94,9 +96,19 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request {
.request => |r| r._headers,
};
const body = if (opts.body) |b|
try arena.dupe(u8, b)
else switch (input) {
const body = if (opts.body) |b| blk: {
const extracted = try b.extract(arena);
// Per Fetch §6.5 step 11, the default Content-Type only applies if
// the user has not already set one via the headers init dict.
if (extracted.content_type) |ct| {
const hs = headers orelse try Headers.init(null, exec);
if (!hs.has("content-type", exec)) {
try hs.append("content-type", ct, exec);
}
headers = hs;
}
break :blk extracted.bytes;
} else switch (input) {
.url => null,
.request => |r| r._body,
};

View File

@@ -29,8 +29,11 @@ const Page = @import("../../Page.zig");
const Node = @import("../Node.zig");
const Event = @import("../Event.zig");
const Headers = @import("Headers.zig");
const EventTarget = @import("../EventTarget.zig");
const Headers = @import("Headers.zig");
const Request = @import("Request.zig");
const BodyInit = @import("body_init.zig").BodyInit;
const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig");
const log = lp.log;
@@ -221,7 +224,7 @@ pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const
return self._request_headers.append(name, value, exec);
}
pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !void {
if (comptime IS_DEBUG) {
log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url });
}
@@ -231,7 +234,16 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
if (body_) |b| {
if (self._method != .GET and self._method != .HEAD) {
self._request_body = try self._arena.dupe(u8, b);
const extracted = try b.extract(self._arena);
self._request_body = extracted.bytes;
// Per XHR §4.7.6 "send()" step 4, the default Content-Type only
// applies if the author hasn't already set one via
// setRequestHeader.
if (extracted.content_type) |ct| {
if (!self._request_headers.has("content-type", exec_)) {
try self._request_headers.append("content-type", ct, exec_);
}
}
}
}

View File

@@ -0,0 +1,157 @@
// Copyright (C) 2026 Lightpanda (Selecy SAS)
//
// 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 <https://www.gnu.org/licenses/>.
// BodyInit — accepted body shapes for fetch(Request) / XHR.send().
//
// Per Fetch §6.5 "extract a body" (https://fetch.spec.whatwg.org/#concept-bodyinit-extract)
// and XHR §4.7.6 "send()" (https://xhr.spec.whatwg.org/#dom-xmlhttprequest-send),
// the runtime must serialize the body and select the matching default
// Content-Type. Without this layer the JS→Zig bridge falls back to
// toStringSmart() on the JSValue, which sends "[object FormData]" for
// FormData (issue #2357) and skips the multipart encoding wired up at
// FormData.multipartEncode (./FormData.zig:198).
//
// The union arms are ordered so the bridge's tagged-union prober matches
// the most specific JsApi class first; the trailing `bytes: []const u8`
// arm soaks up strings (and via .coerce, anything string-like) so plain
// text bodies still work unchanged.
const std = @import("std");
const Blob = @import("../Blob.zig");
const FormData = @import("FormData.zig");
const URLSearchParams = @import("URLSearchParams.zig");
const Allocator = std.mem.Allocator;
pub const BodyInit = union(enum) {
blob: *Blob,
form_data: *FormData,
url_search_params: *URLSearchParams,
bytes: []const u8, // must be last, js.Bridge will map anything to a string
pub fn extract(self: BodyInit, arena: Allocator) !Extracted {
switch (self) {
.bytes => |b| {
// String bodies: dupe as-is. Per Fetch §6.5 step 4, the default
// Content-Type for USVString is "text/plain;charset=UTF-8";
// emit it so callers without an explicit header still pass spec
// checks. Pre-fix behaviour also omitted this; tests that depend
// on no Content-Type for string bodies should set one explicitly.
return .{
.bytes = try arena.dupe(u8, b),
.content_type = "text/plain;charset=UTF-8",
};
},
.url_search_params => |usp| {
var buf = std.Io.Writer.Allocating.init(arena);
try usp.toString(&buf.writer);
return .{
.bytes = buf.written(),
.content_type = "application/x-www-form-urlencoded;charset=UTF-8",
};
},
.form_data => |fd| {
var rand_bytes: [10]u8 = undefined;
std.crypto.random.bytes(&rand_bytes);
const hex = std.fmt.bytesToHex(rand_bytes, .lower);
var boundary: [24]u8 = undefined;
@memcpy(boundary[0..4], "----");
@memcpy(boundary[4..], &hex);
var buf = std.Io.Writer.Allocating.init(arena);
try fd.write(.{
.allocator = arena,
.encoding = .{ .formdata = &boundary },
}, &buf.writer);
const ct = try std.fmt.allocPrint(arena, "multipart/form-data; boundary={s}", .{boundary});
return .{
.bytes = buf.written(),
.content_type = ct,
};
},
.blob => |blob| {
return .{
.bytes = try arena.dupe(u8, blob._slice),
.content_type = if (blob._mime.len > 0) try arena.dupe(u8, blob._mime) else null,
};
},
}
}
};
// Result of extracting a body. `bytes` is duped into the caller's arena.
// `content_type`, when non-null, is the spec-mandated default Content-Type
// for the body source — callers MUST only apply it if the user has not
// already set a Content-Type header (per Fetch §6.5).
pub const Extracted = struct {
bytes: []const u8,
content_type: ?[]const u8,
};
const testing = @import("../../../testing.zig");
test "BodyInit: bytes pass through with text/plain" {
defer testing.reset();
const r = try (BodyInit{ .bytes = "hello" }).extract(testing.arena_allocator);
try testing.expectString("hello", r.bytes);
try testing.expectString("text/plain;charset=UTF-8", r.content_type.?);
}
test "BodyInit: URLSearchParams emit urlencoded body + content-type" {
defer testing.reset();
const arena = testing.arena_allocator;
const usp = try arena.create(URLSearchParams);
usp.* = .{ ._arena = arena, ._params = .empty };
try usp.append("a", "1");
try usp.append("b", "2");
const r = try (BodyInit{ .url_search_params = usp }).extract(arena);
try testing.expectString("a=1&b=2", r.bytes);
try testing.expectString("application/x-www-form-urlencoded;charset=UTF-8", r.content_type.?);
}
test "BodyInit: FormData emits multipart with random boundary" {
defer testing.reset();
const arena = testing.arena_allocator;
const fd = try arena.create(FormData);
fd.* = .{ ._arena = arena, ._entries = .empty };
try fd.append("username", "alice");
try fd.append("email", "alice@example.com");
const r = try (BodyInit{ .form_data = fd }).extract(arena);
// Body must contain the entries' Content-Disposition lines and end with
// the closing boundary marker.
const boundary = r.content_type.?["multipart/form-data; boundary=".len..];
try testing.expectEqual(true, std.mem.startsWith(u8, boundary, "----"));
try testing.expectEqual(true, boundary.len > 10);
try testing.expect(std.mem.indexOf(u8, r.bytes, "Content-Disposition: form-data; name=\"username\"") != null);
try testing.expect(std.mem.indexOf(u8, r.bytes, "Content-Disposition: form-data; name=\"email\"") != null);
try testing.expect(std.mem.indexOf(u8, r.bytes, "alice") != null);
try testing.expect(std.mem.indexOf(u8, r.bytes, "alice@example.com") != null);
const closer = try std.fmt.allocPrint(arena, "--{s}--\r\n", .{boundary});
try testing.expect(std.mem.endsWith(u8, r.bytes, closer));
}
// Blob.extract is exercised end-to-end by the Request/XHR HTML fixture
// tests rather than constructed ad-hoc here — Blob owns `_type`, `_rc`,
// and `_arena` fields that need a Page-backed allocator to initialise
// safely.

View File

@@ -365,6 +365,16 @@ pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool {
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
return false;
}
// Reentrant teardown from a CDP message drained inside HttpClient.syncRequest.
// Tearing down the browser context here would free Session/Page state
// that the unwinding script-eval frame above us is about to dereference
// (see Session.removePage's matching guard). Defer cleanup to
// CDP.deinit at connection close, by which time eval has unwound.
if (bc.session.currentPage()) |page| {
if (page.frame._script_manager.base.is_evaluating) {
return true;
}
}
bc.deinit();
self.browser.closeSession();
self.browser_context = null;