From 6eeb97c2202bf9d3b2c288d2d86acc14fb4490bb Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 8 May 2026 13:10:30 +0800 Subject: [PATCH] CustomElement Reactions While this PR touches a lot of files, and isn't trivial, many of the changes are either: 1 - removing guards added in previous PRs, e.g. https://github.com/lightpanda-io/browser/pull/1969 https://github.com/lightpanda-io/browser/pull/2172 https://github.com/lightpanda-io/browser/pull/2313 https://github.com/lightpanda-io/browser/pull/2366 2 - Adding the `.ce_reactions = true` flag to various WebAPIs CustomElements have callbacks, e.g. connectedCallback. Also, many WebAPI calls are implemented as a series of mutations, e.g. appendChild = remove from current + append to new. These two things interact in an important way: when should callbacks execute? Before this PR, we were invoking callbacks at each individual step. This is (a) technically wrong and (b) breaks a lot of assumptions (the reason the above 4 PRs were needed to fix bugs). This PR adds a `_ce_reactions` queue to the frame. And, instead of invoking callbacks, we "enqueue" the reaction. At various boundaries, a scope is created the DOM manipulation is done, and then we pop the scope, invoking all queued reactions. --- src/browser/CustomElementReactions.zig | 129 ++++++++++++++ src/browser/Frame.zig | 61 +++---- src/browser/ScriptManagerBase.zig | 7 + src/browser/js/Caller.zig | 21 +++ src/browser/js/bridge.zig | 12 ++ src/browser/parser/Parser.zig | 22 +++ .../mutation_during_callback.html | 74 ++++---- src/browser/tests/node/append_child.html | 51 +++--- src/browser/tests/node/insert_before.html | 20 +-- src/browser/webapi/CData.zig | 18 +- src/browser/webapi/CustomElementRegistry.zig | 11 +- src/browser/webapi/DOMParser.zig | 2 +- src/browser/webapi/Document.zig | 14 +- src/browser/webapi/DocumentFragment.zig | 8 +- src/browser/webapi/Element.zig | 67 ++++---- src/browser/webapi/Node.zig | 44 ++--- .../webapi/collections/DOMTokenList.zig | 10 +- src/browser/webapi/element/Attribute.zig | 6 +- src/browser/webapi/element/html/Custom.zig | 161 ++++++++++-------- 19 files changed, 447 insertions(+), 291 deletions(-) create mode 100644 src/browser/CustomElementReactions.zig diff --git a/src/browser/CustomElementReactions.zig b/src/browser/CustomElementReactions.zig new file mode 100644 index 00000000..d7e9b1dc --- /dev/null +++ b/src/browser/CustomElementReactions.zig @@ -0,0 +1,129 @@ +// 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. + +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 Self = @This(); + +allocator: Allocator, +queue: std.ArrayList(Reaction) = .empty, +// Number of currently-open scopes (push() that hasn't been pop'd). Every +// enqueue must happen inside a scope — that's the leak-detection invariant. +// Checked in debug at enqueue time so leaks surface where the bug is, not +// later at some unrelated boundary. +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 { + for (self.queue.items[checkpoint..]) |reaction| { + Custom.fireReaction(reaction, frame); + } + self.queue.items.len = checkpoint; + self.active_scopes -= 1; +} + +inline fn assertScopeActive(self: *const Self) void { + lp.assert(self.active_scopes > 0, "ce_reactions enqueue without active scope", .{}); +} + +pub fn enqueueConnected(self: *Self, element: *Element) !void { + self.assertScopeActive(); + try self.queue.append(self.allocator, .{ .connected = element }); +} + +pub fn enqueueDisconnected(self: *Self, element: *Element) !void { + self.assertScopeActive(); + try self.queue.append(self.allocator, .{ .disconnected = element }); +} + +pub fn enqueueAdopted(self: *Self, element: *Element, old_document: *Document, new_document: *Document) !void { + self.assertScopeActive(); + try self.queue.append(self.allocator, .{ .adopted = .{ + .element = element, + .old_document = old_document, + .new_document = new_document, + } }); +} + +pub fn enqueueAttributeChanged( + self: *Self, + element: *Element, + name: String, + old_value: ?String, + new_value: ?String, + namespace: ?String, +) !void { + self.assertScopeActive(); + try self.queue.append(self.allocator, .{ .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 49d82d07..e92e00aa 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, @@ -291,6 +299,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; @@ -1965,7 +1974,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(); @@ -2701,7 +2710,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 @@ -3094,7 +3103,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