diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index cf14fb8f..1c6177ee 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -18,18 +18,17 @@ const std = @import("std"); -const Allocator = std.mem.Allocator; +const App = @import("../App.zig"); +const CDP = @import("../cdp/CDP.zig"); +const Notification = @import("../Notification.zig"); const js = @import("js/js.zig"); -const App = @import("../App.zig"); +const Page = @import("Page.zig"); +const Session = @import("Session.zig"); const HttpClient = @import("HttpClient.zig"); -const CDP = @import("../cdp/CDP.zig"); const ArenaPool = App.ArenaPool; - -const Session = @import("Session.zig"); -const Page = @import("Page.zig"); -const Notification = @import("../Notification.zig"); +const Allocator = std.mem.Allocator; // Browser is an instance of the browser. // You can create multiple browser instances. @@ -46,6 +45,14 @@ http_client: HttpClient, // used by sessions to allocate pages. page_pool: std.heap.MemoryPool(Page), +// Pool for FinalizerCallback.Identity structs — the records V8 weak-callback +// parameters point at. Scoped to the Browser (i.e. the V8 Isolate's lifetime) +// rather than the Session: V8 can run a weak finalizer arbitrarily late, any +// time up until the Isolate is torn down, so these must outlive every Session. +// Freed in deinit *after* env.deinit() tears down the Isolate — the point past +// which no finalizer can fire. +fc_identity_pool: std.heap.MemoryPool(js.FinalizerCallback.Identity), + // Monotonic frame-ID generator scoped to this Browser (one per CDP // connection). Lives here, not on Session, because CDP target IDs // (encoded as `FID-{d:0>10}`) must be unique for the lifetime of the @@ -84,6 +91,7 @@ pub fn init(self: *Browser, app: *App, opts: InitOpts, cdp: ?*CDP) !void { .arena_pool = &app.arena_pool, .http_client = undefined, .page_pool = std.heap.MemoryPool(Page).init(allocator), + .fc_identity_pool = .init(allocator), }; try self.http_client.init(allocator, &app.network, cdp); } @@ -91,6 +99,9 @@ pub fn init(self: *Browser, app: *App, opts: InitOpts, cdp: ?*CDP) !void { pub fn deinit(self: *Browser) void { self.closeSession(); self.env.deinit(); + // After env.deinit() the Isolate is gone, so no further weak finalizer can + // fire — only now is it safe to free the pool backing their parameters. + self.fc_identity_pool.deinit(); self.page_pool.deinit(); self.http_client.deinit(); } diff --git a/src/browser/CustomElementReactions.zig b/src/browser/CustomElementReactions.zig new file mode 100644 index 00000000..b14d77b3 --- /dev/null +++ b/src/browser/CustomElementReactions.zig @@ -0,0 +1,162 @@ +// 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 . + +// Implements the spec's "custom element reactions" mechanism: callbacks +// (connectedCallback, disconnectedCallback, adoptedCallback, +// attributeChangedCallback) are enqueued during DOM mutation and invoked at +// the outer algorithm boundary, not synchronously mid-mutation. +// +// The "stack of element queues" is collapsed to a single flat ArrayList plus +// per-scope checkpoint indices: push() captures items.len, popAndInvoke() +// drains items[checkpoint..] and truncates. Nested scopes work naturally — +// inside a callback, a new scope captures its own checkpoint past the current +// length, drains its own range, and the outer iteration continues from where +// it left off. +// +// When a reaction is enqueued without an active scope (e.g. a Web API path +// that wasn't tagged `.ce_reactions = true`, or a non-WebIDL entry point), +// it goes on the backup queue instead and a microtask is scheduled to drain +// it. This matches the spec's "backup element queue" so missing bridge tags +// degrade to delayed reactions rather than crashes. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const Frame = @import("Frame.zig"); +const Element = @import("webapi/Element.zig"); +const Document = @import("webapi/Document.zig"); +const Custom = @import("webapi/element/html/Custom.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; + +const Self = @This(); + +allocator: Allocator, +queue: std.ArrayList(Reaction) = .empty, + +backup_scheduled: bool = false, +backup_queue: std.ArrayList(Reaction) = .empty, + +// Number of currently-open scopes (push() that hasn't been pop'd). When 0, +// enqueues route to the backup queue and rely on a microtask to drain. +active_scopes: u32 = 0, + +/// Open a new reactions scope. Returns a checkpoint to be passed to popAndInvoke. +pub fn push(self: *Self) usize { + self.active_scopes += 1; + return self.queue.items.len; +} + +/// Drain reactions queued at indices >= checkpoint, then truncate. Reactions +/// enqueued within a nested scope drain at that scope's pop, before this loop +/// sees them. +pub fn popAndInvoke(self: *Self, checkpoint: usize, frame: *Frame) void { + // Index, not slice: firing a reaction can recursively enqueue (via JS + // callbacks doing DOM mutations), which may realloc queue.items and + // invalidate any captured slice. + var i = checkpoint; + while (i < self.queue.items.len) : (i += 1) { + Custom.fireReaction(self.queue.items[i], frame); + } + self.queue.items.len = checkpoint; + self.active_scopes -= 1; +} + +/// Drain the backup queue. Called from the scheduled microtask. `backup_scheduled` +/// stays true while draining so new enqueues append to backup_queue and get picked +/// up by the same loop instead of scheduling a redundant microtask. +pub fn drainBackup(self: *Self, frame: *Frame) void { + var i: usize = 0; + while (i < self.backup_queue.items.len) : (i += 1) { + Custom.fireReaction(self.backup_queue.items[i], frame); + } + self.backup_queue.clearRetainingCapacity(); + self.backup_scheduled = false; +} + +fn route(self: *Self, frame: *Frame, reaction: Reaction) !void { + if (self.active_scopes > 0) { + try self.queue.append(self.allocator, reaction); + return; + } + if (comptime IS_DEBUG) { + lp.log.err(.bug, "custom element scope", .{ .note = "Missing explicit reaction scope, using fallback. This log is only generated in debug builds." }); + } + try self.backup_queue.append(self.allocator, reaction); + if (!self.backup_scheduled) { + try frame.scheduleCustomElementBackupDrain(); + self.backup_scheduled = true; + } +} + +pub fn enqueueConnected(self: *Self, frame: *Frame, element: *Element) !void { + try self.route(frame, .{ .connected = element }); +} + +pub fn enqueueDisconnected(self: *Self, frame: *Frame, element: *Element) !void { + try self.route(frame, .{ .disconnected = element }); +} + +pub fn enqueueAdopted(self: *Self, frame: *Frame, element: *Element, old_document: *Document, new_document: *Document) !void { + try self.route(frame, .{ .adopted = .{ + .element = element, + .old_document = old_document, + .new_document = new_document, + } }); +} + +pub fn enqueueAttributeChanged( + self: *Self, + frame: *Frame, + element: *Element, + name: String, + old_value: ?String, + new_value: ?String, + namespace: ?String, +) !void { + try self.route(frame, .{ .attribute_changed = .{ + .name = name, + .element = element, + .old_value = old_value, + .new_value = new_value, + .namespace = namespace, + } }); +} + +pub const Reaction = union(enum) { + connected: *Element, + disconnected: *Element, + adopted: Adopted, + attribute_changed: AttributeChanged, + + pub const Adopted = struct { + element: *Element, + old_document: *Document, + new_document: *Document, + }; + + pub const AttributeChanged = struct { + element: *Element, + name: String, + old_value: ?String, + new_value: ?String, + namespace: ?String, + }; +}; diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 3544cf11..024093ca 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -32,6 +32,8 @@ const StyleManager = @import("StyleManager.zig"); const Parser = @import("parser/Parser.zig"); const h5e = @import("parser/html5ever.zig"); +const CustomElementReactions = @import("CustomElementReactions.zig"); + const URL = @import("URL.zig"); const Blob = @import("webapi/Blob.zig"); const Node = @import("webapi/Node.zig"); @@ -185,6 +187,12 @@ _upgrading_element: ?*Node = null, // List of custom elements that were created before their definition was registered _undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{}, +// Pending custom-element reactions (connected/disconnected/adopted/attribute +// changed). Reactions are enqueued during DOM mutation and drained at the +// outer algorithm boundary — set up by the JS bridge for [CEReactions] +// methods and by the parser pump on each yield. +_ce_reactions: CustomElementReactions, + // for heap allocations and managing WebAPI objects _factory: *Factory, @@ -295,6 +303,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void { ._type = if (parent == null) .root else .frame, ._style_manager = undefined, ._script_manager = undefined, + ._ce_reactions = .{ .allocator = arena }, ._event_manager = EventManager.init(arena, self), }; self._to_load = &self._to_load_1; @@ -1822,6 +1831,10 @@ pub fn scheduleSlotchangeDelivery(self: *Frame) !void { try self.js.queueSlotchangeDelivery(); } +pub fn scheduleCustomElementBackupDrain(self: *Frame) !void { + try self.js.queueCustomElementBackupDrain(); +} + pub fn performScheduledIntersectionChecks(self: *Frame) void { if (!self._intersection_check_scheduled) { return; @@ -1971,7 +1984,7 @@ pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: // Per spec, adopted steps run on each element after its document is set. if (node.is(Element)) |el| { - Element.Html.Custom.invokeAdoptedCallbackOnElement(el, old_owner, new_owner, self); + Element.Html.Custom.enqueueAdoptedCallbackOnElement(el, old_owner, new_owner, self); } var it = node.childrenIterator(); @@ -2707,7 +2720,7 @@ pub fn createElementNS(self: *Frame, namespace: Element.Namespace, name: []const if (element._attributes) |attributes| { var it = attributes.iterator(); while (it.next()) |attr| { - Element.Html.Custom.invokeAttributeChangedCallbackOnElement( + Element.Html.Custom.enqueueAttributeChangedCallbackOnElement( element, attr._name, null, // old_value is null for initial attributes @@ -3100,7 +3113,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt self.removeElementIdWithMaps(id_maps.?, id); } - Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self); + Element.Html.Custom.enqueueDisconnectedCallbackOnElement(el, self); // If a