Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-05-12 14:42:29 +02:00
29 changed files with 375 additions and 471 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.4.4'
default: 'v0.4.5'
v8:
description: 'v8 version to install'
required: false

31
AGENTS.md Normal file
View File

@@ -0,0 +1,31 @@
# AGENTS.md
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to open a pull request (CLA, dev setup, pre-PR checks).
## Tests
```bash
make test # Run all tests
make test F="server" # Filter by substring
TEST_FILTER="WebApi: #selector_all" make test # Filter main + subtest (separator: #)
TEST_VERBOSE=true make test
TEST_FAIL_FIRST=true make test
METRICS=true make test # Capture allocation/duration metrics as JSON
```
The custom test runner (`src/test_runner.zig`) detects memory leaks in debug builds. **A test that allocates without freeing fails** — not just lints.
## Formatting
```bash
zig fmt --check ./*.zig ./**/*.zig # Exact command CI runs
```
`zig build` depends on the fmt step, so a local build catches drift too.
## Conventions
Mirror the patterns in neighboring files. In particular:
- `@import` alias case follows the imported file's basename (`const Frame = @import("Frame.zig")`, `const ast = @import("ast.zig")`).
- Prefer struct-init type inference (`.{ ... }`) where the expected type is known from the function signature or variable annotation.

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -2,6 +2,21 @@
Lightpanda accepts pull requests through GitHub.
## Development
- Run the tests: `make test`
- Check formatting: `zig fmt --check ./*.zig ./**/*.zig`
See [AGENTS.md](AGENTS.md) for the full set of test, formatting, and code conventions (test filters, the leak-detection invariant, `@import` alias case, struct-init inference).
## Before opening a PR
- [ ] Tests pass (`make test`).
- [ ] Formatting is clean (`zig fmt --check ./*.zig ./**/*.zig`).
- [ ] CLA signed (see below).
## CLA
You have to sign our [CLA](CLA.md) during your first pull request process
otherwise we're not able to accept your contributions.

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.4.4
ARG ZIG_V8=v0.4.5
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -286,6 +286,15 @@ See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details
You can test Lightpanda by running `make test`.
```bash
make test # Run all tests
make test F="server" # Filter by substring
TEST_FILTER="WebApi: #selector_all" make test # Filter main + subtest (separator: #)
TEST_VERBOSE=true make test
TEST_FAIL_FIRST=true make test
METRICS=true make test # Capture allocation/duration metrics as JSON
```
### End to end tests
To run end to end tests, you need to clone the [demo

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.4.tar.gz",
.hash = "v8-0.0.0-xddH6xiUBACrbPC02HPqL4IOl_1EKAF6zf0IwNKaCILK",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.5.tar.gz",
.hash = "v8-0.0.0-xddH65CXBADLRFlCM_pECcwsoY-9P9mZ7VnYM-6V3mXW",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -1,129 +0,0 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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/>.
// 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,
};
};

View File

@@ -32,8 +32,6 @@ 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");
@@ -190,12 +188,6 @@ _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,
@@ -296,7 +288,6 @@ 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),
._console_messages = .init(arena),
};
@@ -1868,7 +1859,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.enqueueAdoptedCallbackOnElement(el, old_owner, new_owner, self);
Element.Html.Custom.invokeAdoptedCallbackOnElement(el, old_owner, new_owner, self);
}
var it = node.childrenIterator();
@@ -2604,7 +2595,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.enqueueAttributeChangedCallbackOnElement(
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(
element,
attr._name,
null, // old_value is null for initial attributes
@@ -3026,7 +3017,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
self.removeElementIdWithMaps(id_maps.?, id);
}
Element.Html.Custom.enqueueDisconnectedCallbackOnElement(el, self);
Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self);
// If a <style> element is being removed, remove its sheet from the list
if (el.is(Element.Html.Style)) |style| {
@@ -3049,8 +3040,11 @@ pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
self.domChanged();
const dest_connected = target.isConnected();
var it = parent.childrenIterator();
while (it.next()) |child| {
// Use firstChild() instead of iterator to handle cases where callbacks
// (like custom element connectedCallback) modify the parent during iteration.
// The iterator captures "next" pointers that can become stale.
while (parent.firstChild()) |child| {
// Check if child was connected BEFORE removing it from parent
const child_was_connected = child.isConnected();
self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected });
try self.appendNode(target, child, .{ .child_already_connected = child_was_connected });
@@ -3061,16 +3055,31 @@ pub fn insertAllChildrenBefore(self: *Frame, fragment: *Node, parent: *Node, ref
self.domChanged();
const dest_connected = parent.isConnected();
var it = fragment.childrenIterator();
while (it.next()) |child| {
// Use firstChild() instead of iterator to handle cases where callbacks
// (like custom element connectedCallback) modify the fragment during iteration.
// The iterator captures "next" pointers that can become stale.
while (fragment.firstChild()) |child| {
// Check if child was connected BEFORE removing it from fragment
const child_was_connected = child.isConnected();
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
try self.insertNodeRelative(
parent,
child,
.{ .before = ref_node },
.{ .child_already_connected = child_was_connected },
);
// A callback fired by a previous iteration's insert (e.g. a custom
// element's connectedCallback) may have detached ref_node from
// parent. In that case, fall back to append so the remaining
// children still land in `parent` in source order.
if (ref_node._parent == parent) {
try self.insertNodeRelative(
parent,
child,
.{ .before = ref_node },
.{ .child_already_connected = child_was_connected },
);
} else {
try self.appendNode(
parent,
child,
.{ .child_already_connected = child_was_connected },
);
}
}
}
@@ -3194,7 +3203,7 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
try self.addElementId(parent, el, id);
}
try Element.Html.Custom.enqueueConnectedCallbackOnElement(true, el, self);
try Element.Html.Custom.invokeConnectedCallbackOnElement(true, el, self);
}
}
return;
@@ -3240,7 +3249,7 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
}
if (should_invoke_connected) {
try Element.Html.Custom.enqueueConnectedCallbackOnElement(false, el, self);
try Element.Html.Custom.invokeConnectedCallbackOnElement(false, el, self);
}
}
}
@@ -3250,7 +3259,7 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str
log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type, .url = self.url });
};
Element.Html.Custom.enqueueAttributeChangedCallbackOnElement(element, name, old_value, value, null, self);
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, null, self);
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
@@ -3276,7 +3285,7 @@ pub fn attributeRemove(self: *Frame, element: *Element, name: String, old_value:
log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type, .url = self.url });
};
Element.Html.Custom.enqueueAttributeChangedCallbackOnElement(element, name, old_value, null, null, self);
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, null, self);
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {

View File

@@ -749,13 +749,6 @@ pub const Script = struct {
try_catch.init(local);
defer try_catch.deinit();
// Custom-element reactions: the script body is a JS-execution
// boundary. Open a scope so any reactions it queues (or that were
// queued by the parser since the previous boundary) drain at the
// end of the script, before the parser resumes.
const ce_checkpoint = frame._ce_reactions.push();
defer frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
const success = blk: {
const content = self.source.content();
switch (fe.kind) {

View File

@@ -569,7 +569,6 @@ pub const Function = struct {
null_as_undefined: bool = false,
cache: ?Caching = null,
embedded_receiver: bool = false,
ce_reactions: bool = false,
// We support two ways to cache a value directly into a v8::Object. The
// difference between the two is like the difference between a Map
@@ -622,26 +621,6 @@ pub const Function = struct {
caller.initWithContext(ctx, v8_context);
defer caller.deinit();
// [CEReactions] entry: open a reactions scope so any custom-element
// callbacks queued by DOM mutation inside `func` fire after it
// returns, never mid-algorithm.
var ce_checkpoint: usize = undefined;
const ce_frame: ?*Frame = if (comptime opts.ce_reactions) switch (ctx.global) {
.frame => |frame| frame,
.worker => null,
} else null;
if (comptime opts.ce_reactions) {
if (ce_frame) |frame| {
ce_checkpoint = frame._ce_reactions.push();
}
}
defer if (comptime opts.ce_reactions) {
if (ce_frame) |frame| {
frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
}
};
const js_value = _call(T, &caller.local, info, func, opts) catch |err| {
handleError(T, @TypeOf(func), &caller.local, err, info, .{
.dom_exception = opts.dom_exception,

View File

@@ -130,18 +130,6 @@ pub const Constructor = struct {
}
defer caller.deinit();
// Constructors are a JS-execution boundary, just like
// [CEReactions] methods. Open a reactions scope so any
// callbacks queued by the user's constructor body (or
// by attribute_changed reactions queued before invocation)
// drain at the constructor's exit, not later.
const ce_frame: ?*Frame = switch (caller.local.ctx.global) {
.frame => |frame| frame,
.worker => null,
};
const ce_checkpoint: usize = if (ce_frame) |frame| frame._ce_reactions.push() else 0;
defer if (ce_frame) |frame| frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
caller.constructor(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.new_target = opts.new_target,
@@ -990,9 +978,11 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/AbortController.zig"),
@import("../webapi/URL.zig"),
@import("../webapi/canvas/OffscreenCanvas.zig"),
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
@import("../webapi/net/XMLHttpRequest.zig"),
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/ImageData.zig"),
// @import("../webapi/Performance.zig"),
});

View File

@@ -346,8 +346,6 @@ fn parseErrorCallback(ctx: *anyopaque, err: h5e.StringSlice) callconv(.c) void {
fn popCallback(ctx: *anyopaque, node_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._popCallback(getNode(node_ref)) catch |err| {
self.err = .{ .err = err, .source = .pop };
};
@@ -370,8 +368,6 @@ fn createXMLElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualNa
fn _createElementCallbackWithDefaultnamespace(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator, default_namespace: Element.Namespace) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
return self._createElementCallback(data, qname, attributes, default_namespace) catch |err| {
self.err = .{ .err = err, .source = .create_element };
return null;
@@ -394,8 +390,6 @@ fn _createElementCallback(self: *Parser, data: *anyopaque, qname: h5e.QualName,
fn createCommentCallback(ctx: *anyopaque, str: h5e.StringSlice) callconv(.c) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
return self._createCommentCallback(str.slice()) catch |err| {
self.err = .{ .err = err, .source = .create_comment };
return null;
@@ -414,8 +408,6 @@ fn _createCommentCallback(self: *Parser, str: []const u8) !*anyopaque {
fn createProcessingInstruction(ctx: *anyopaque, target: h5e.StringSlice, data: h5e.StringSlice) callconv(.c) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
return self._createProcessingInstruction(target.slice(), data.slice()) catch |err| {
self.err = .{ .err = err, .source = .create_processing_instruction };
return null;
@@ -434,8 +426,6 @@ fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const
fn appendDoctypeToDocument(ctx: *anyopaque, name: h5e.StringSlice, public_id: h5e.StringSlice, system_id: h5e.StringSlice) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendDoctypeToDocument(name.slice(), public_id.slice(), system_id.slice()) catch |err| {
self.err = .{ .err = err, .source = .append_doctype_to_document };
};
@@ -458,8 +448,6 @@ fn _appendDoctypeToDocument(self: *Parser, name: []const u8, public_id: []const
fn addAttrsIfMissingCallback(ctx: *anyopaque, target_ref: *anyopaque, attributes: h5e.AttributeIterator) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._addAttrsIfMissingCallback(getNode(target_ref), attributes) catch |err| {
self.err = .{ .err = err, .source = .add_attrs_if_missing };
};
@@ -509,8 +497,6 @@ fn getDataCallback(ctx: *anyopaque) callconv(.c) *anyopaque {
fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendCallback(getNode(parent_ref), node_or_text) catch |err| {
self.err = .{ .err = err, .source = .append };
};
@@ -543,8 +529,6 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._removeFromParentCallback(getNode(target_ref)) catch |err| {
self.err = .{ .err = err, .source = .remove_from_parent };
};
@@ -561,8 +545,6 @@ fn _removeFromParentCallback(self: *Parser, node: *Node) !void {
fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._reparentChildrenCallback(getNode(node_ref), getNode(new_parent_ref)) catch |err| {
self.err = .{ .err = err, .source = .reparent_children };
};
@@ -577,8 +559,6 @@ fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !voi
fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendBeforeSiblingCallback(getNode(sibling_ref), node_or_text) catch |err| {
self.err = .{ .err = err, .source = .append_before_sibling };
};
@@ -607,8 +587,6 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e
fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, prev_element_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendBasedOnParentNodeCallback(getNode(element_ref), getNode(prev_element_ref), node_or_text) catch |err| {
self.err = .{ .err = err, .source = .append_based_on_parent_node };
};

View File

@@ -2,19 +2,19 @@
<body>
<script src="../testing.js"></script>
<!-- Custom-element reactions queue during outerHTML's algorithm and fire
at the boundary, not mid-loop. By the time connectedCallback runs,
insertAllChildrenBefore has already inserted every fragment child
and setOuterHTML has detached the original `target`, so the callback
sees the post-state — its check observes target already detached and
leaves the DOM untouched. -->
<script id="outerHTML_callback_observes_post_state">
<!-- Regression: Frame.insertAllChildrenBefore (used by Element.outerHTML
setter) loops over fragment children and inserts each one .before
ref_node. _insertNodeRelative invokes connectedCallback for custom
elements, and that callback can detach ref_node from parent. The
next iteration must not panic on `ref_node._parent.? == parent`. -->
<script id="outerHTML_callback_detaches_ref_node">
{
let targetParentAtCallback = undefined;
class DetachTarget extends HTMLElement {
connectedCallback() {
const target = document.getElementById('outer_target');
targetParentAtCallback = target && target.parentNode;
if (target && target.parentNode) {
target.parentNode.removeChild(target);
}
}
}
customElements.define('detach-target-1', DetachTarget);
@@ -26,48 +26,60 @@
target.id = 'outer_target';
wrap.appendChild(target);
// Two siblings: the first one's connectedCallback removes `target`
// (which is the ref_node passed to insertAllChildrenBefore). The
// second one then tries to insert before a parentless ref_node.
target.outerHTML = '<detach-target-1></detach-target-1><b id="second">x</b>';
// Both replacement children landed; the original target was removed
// before the queued connectedCallback ran.
// We don't strictly care about the final shape, just that we got
// here without crashing. Sanity-check the first child landed.
testing.expectTrue(wrap.querySelector('detach-target-1') !== null);
testing.expectTrue(wrap.querySelector('#second') !== null);
testing.expectEqual(null, targetParentAtCallback);
}
</script>
<!-- innerHTML removes existing children, then parses and inserts the
new content. With queued reactions, the disconnectedCallbacks for
the removed elements fire after the whole algorithm completes — so
they observe `host` already populated with the new content. -->
<script id="innerHTML_disconnected_observes_post_state">
<!-- Regression: Element.setInnerHTML removes existing children using
`childrenIterator()`. removeNode fires disconnectedCallback for
custom elements, and that callback can mutate the host's child
list, leaving the iterator advancing through a node that no longer
belongs to the parent. -->
<script id="innerHTML_disconnected_mutates_siblings">
{
let host;
let lastSeenChildCount = -1;
let lastSeenFirstTag = null;
class SiblingObserver extends HTMLElement {
let removeNextOnDisconnect = false;
class SiblingZapper extends HTMLElement {
disconnectedCallback() {
lastSeenChildCount = host.children.length;
lastSeenFirstTag = host.children.length > 0 ? host.children[0].tagName : null;
if (!removeNextOnDisconnect) return;
removeNextOnDisconnect = false;
// Reach into the parent (captured via closure) to remove a
// sibling that the outer setInnerHTML loop is about to visit.
// The element being removed here is what the iterator's
// current node still points its `next` pointer at.
if (host.children.length > 0) {
host.removeChild(host.children[0]);
}
}
}
customElements.define('sibling-zapper-2', SiblingObserver);
customElements.define('sibling-zapper-2', SiblingZapper);
host = document.createElement('div');
document.body.appendChild(host);
host.appendChild(document.createElement('sibling-zapper-2'));
host.appendChild(document.createElement('sibling-zapper-2'));
host.appendChild(document.createElement('span'));
const a = document.createElement('sibling-zapper-2');
const b = document.createElement('sibling-zapper-2');
const c = document.createElement('span');
host.appendChild(a);
host.appendChild(b);
host.appendChild(c);
removeNextOnDisconnect = true;
// Triggers the removal loop in setInnerHTML. Removing `a` fires its
// disconnectedCallback, which removes `b` (the iterator's next
// target). The loop must not visit a node whose `_parent` is null.
host.innerHTML = '<i>after</i>';
// The disconnectedCallback fires after innerHTML completes; by then
// the new <i> is already in place.
testing.expectEqual(1, host.children.length);
testing.expectEqual('I', host.children[0].tagName);
testing.expectEqual(1, lastSeenChildCount);
testing.expectEqual('I', lastSeenFirstTag);
}
</script>
</body>

View File

@@ -31,19 +31,18 @@
<div id=d3></div>
<script id=appendChild_fragment_mutation>
// Custom-element reactions are queued during the appendChild algorithm
// and fire after it completes. The connectedCallback below sees the
// post-move DOM state — both fragment children are already in d3 — so
// its check `b.parentNode === fragment` is false and it leaves b alone.
// Test that appendChild with DocumentFragment handles synchronous callbacks
// (like custom element connectedCallback) that modify the fragment during iteration.
// This reproduces a bug where the iterator captures "next" node pointers
// before processing, but callbacks can remove those nodes from the fragment.
const d3 = $('#d3');
const fragment = document.createDocumentFragment();
// Create custom element whose connectedCallback modifies the fragment
let bElement = null;
let parentSeenAtCallback = null;
class ModifyingElement extends HTMLElement {
connectedCallback() {
// Callback fires after the move; record what we observe.
parentSeenAtCallback = bElement && bElement.parentNode;
// When this element is connected, remove 'b' from the fragment
if (bElement && bElement.parentNode === fragment) {
fragment.removeChild(bElement);
}
@@ -59,37 +58,39 @@
fragment.appendChild(a);
fragment.appendChild(b);
// This should not crash - appendChild should handle the modification gracefully
d3.appendChild(fragment);
// Both moved atomically. Callback observed b already in d3.
assertChildren(['a', 'b'], d3);
testing.expectEqual(d3, parentSeenAtCallback);
// 'a' should be in d3, 'b' was removed by connectedCallback and is now detached
assertChildren(['a'], d3);
testing.expectEqual(null, b.parentNode);
</script>
<div id=d4></div>
<div id=d4_stash></div>
<script id=appendChild_disconnect_callback_observes_post_move>
// Custom-element reactions queue and drain at the algorithm boundary, so
// disconnectedCallback fires after the whole move completes. By then the
// element is already in its new (disconnected) parent the callback
// observes the post-move state, never a parentless intermediate.
<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');
let parentSeenAtDisconnect = undefined;
class ObserveOnDisconnect extends HTMLElement {
class ReparentOnDisconnect extends HTMLElement {
disconnectedCallback() {
parentSeenAtDisconnect = this.parentNode;
if (this.parentNode === null) {
stash.appendChild(this);
}
}
}
customElements.define('observe-on-disconnect', ObserveOnDisconnect);
customElements.define('reparent-on-disconnect', ReparentOnDisconnect);
const ood = document.createElement('observe-on-disconnect');
ood.id = 'ood';
d4.appendChild(ood);
const rpd = document.createElement('reparent-on-disconnect');
rpd.id = 'rpd';
d4.appendChild(rpd);
const detached = document.createElement('div');
detached.appendChild(ood);
detached.appendChild(rpd);
testing.expectEqual(detached, ood.parentNode);
testing.expectEqual(detached, parentSeenAtDisconnect);
testing.expectEqual(stash, rpd.parentNode);
</script>

View File

@@ -42,18 +42,21 @@
<div id=d3></div>
<div id=d3_stash></div>
<script id=insertBefore_disconnect_callback_observes_post_move>
// disconnectedCallback fires after insertBefore completes, so the
// callback observes the element already placed in its new parent.
<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');
let parentSeenAtDisconnect = undefined;
class IBObserveOnDisconnect extends HTMLElement {
class IBReparentOnDisconnect extends HTMLElement {
disconnectedCallback() {
parentSeenAtDisconnect = this.parentNode;
if (this.parentNode === null) {
stash.appendChild(this);
}
}
}
customElements.define('ib-reparent-on-disconnect', IBObserveOnDisconnect);
customElements.define('ib-reparent-on-disconnect', IBReparentOnDisconnect);
const moving = document.createElement('ib-reparent-on-disconnect');
moving.id = 'ib_moving';
@@ -65,6 +68,5 @@
detached.insertBefore(moving, ref);
testing.expectEqual(detached, moving.parentNode);
testing.expectEqual(detached, parentSeenAtDisconnect);
testing.expectEqual(stash, moving.parentNode);
</script>

View File

@@ -423,19 +423,19 @@ pub const JsApi = struct {
pub const enumerable = false;
};
pub const data = bridge.accessor(CData.getData, CData._setData, .{ .ce_reactions = true });
pub const data = bridge.accessor(CData.getData, CData._setData, .{});
pub const length = bridge.accessor(CData.getLength, null, .{});
pub const appendData = bridge.function(CData.appendData, .{ .ce_reactions = true });
pub const deleteData = bridge.function(CData.deleteData, .{ .dom_exception = true, .ce_reactions = true });
pub const insertData = bridge.function(CData.insertData, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceData = bridge.function(CData.replaceData, .{ .dom_exception = true, .ce_reactions = true });
pub const appendData = bridge.function(CData.appendData, .{});
pub const deleteData = bridge.function(CData.deleteData, .{ .dom_exception = true });
pub const insertData = bridge.function(CData.insertData, .{ .dom_exception = true });
pub const replaceData = bridge.function(CData.replaceData, .{ .dom_exception = true });
pub const substringData = bridge.function(CData.substringData, .{ .dom_exception = true });
pub const remove = bridge.function(CData.remove, .{ .ce_reactions = true });
pub const before = bridge.function(CData.before, .{ .ce_reactions = true });
pub const after = bridge.function(CData.after, .{ .ce_reactions = true });
pub const replaceWith = bridge.function(CData.replaceWith, .{ .ce_reactions = true });
pub const remove = bridge.function(CData.remove, .{});
pub const before = bridge.function(CData.before, .{});
pub const after = bridge.function(CData.after, .{});
pub const replaceWith = bridge.function(CData.replaceWith, .{});
pub const nextElementSibling = bridge.accessor(CData.nextElementSibling, null, .{});
pub const previousElementSibling = bridge.accessor(CData.previousElementSibling, null, .{});

View File

@@ -190,20 +190,17 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
return error.CustomElementUpgradeFailed;
};
// Enqueue attributeChangedCallback for existing observed attributes
const element = custom.asElement();
var attr_it = element.attributeIterator();
// Invoke attributeChangedCallback for existing observed attributes
var attr_it = custom.asElement().attributeIterator();
while (attr_it.next()) |attr| {
const name = attr._name;
if (definition.isAttributeObserved(name)) {
Custom.enqueueAttributeChangedCallbackOnElement(element, name, null, attr._value, null, frame);
custom.invokeAttributeChangedCallback(name, null, attr._value, null, frame);
}
}
if (node.isConnected()) {
Custom.enqueueConnectedCallbackOnElement(false, element, frame) catch |err| {
log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err });
};
custom.invokeConnectedCallback(frame);
}
}

View File

@@ -118,7 +118,7 @@ pub const JsApi = struct {
};
pub const constructor = bridge.constructor(DOMParser.init, .{});
pub const parseFromString = bridge.function(DOMParser.parseFromString, .{ .ce_reactions = true });
pub const parseFromString = bridge.function(DOMParser.parseFromString, .{});
};
const testing = @import("../../testing.zig");

View File

@@ -1160,15 +1160,15 @@ pub const JsApi = struct {
pub const getSelection = bridge.function(Document.getSelection, .{});
pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{});
pub const getElementsByName = bridge.function(Document.getElementsByName, .{});
pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true, .ce_reactions = true });
pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true, .ce_reactions = true });
pub const append = bridge.function(Document.append, .{ .dom_exception = true, .ce_reactions = true });
pub const prepend = bridge.function(Document.prepend, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .dom_exception = true, .ce_reactions = true });
pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true });
pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true });
pub const append = bridge.function(Document.append, .{ .dom_exception = true });
pub const prepend = bridge.function(Document.prepend, .{ .dom_exception = true });
pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .dom_exception = true });
pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{});
pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{});
pub const write = bridge.function(Document.write, .{ .dom_exception = true, .ce_reactions = true });
pub const writeln = bridge.function(Document.writeln, .{ .dom_exception = true, .ce_reactions = true });
pub const write = bridge.function(Document.write, .{ .dom_exception = true });
pub const writeln = bridge.function(Document.writeln, .{ .dom_exception = true });
pub const open = bridge.function(Document.open, .{ .dom_exception = true });
pub const close = bridge.function(Document.close, .{ .dom_exception = true });
pub const doctype = bridge.accessor(Document.getDocType, null, .{});

View File

@@ -239,10 +239,10 @@ pub const JsApi = struct {
pub const childElementCount = bridge.accessor(DocumentFragment.getChildElementCount, null, .{});
pub const firstElementChild = bridge.accessor(DocumentFragment.firstElementChild, null, .{});
pub const lastElementChild = bridge.accessor(DocumentFragment.lastElementChild, null, .{});
pub const append = bridge.function(DocumentFragment.append, .{ .dom_exception = true, .ce_reactions = true });
pub const prepend = bridge.function(DocumentFragment.prepend, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{ .dom_exception = true, .ce_reactions = true });
pub const innerHTML = bridge.accessor(_innerHTML, DocumentFragment.setInnerHTML, .{ .ce_reactions = true });
pub const append = bridge.function(DocumentFragment.append, .{ .dom_exception = true });
pub const prepend = bridge.function(DocumentFragment.prepend, .{ .dom_exception = true });
pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{ .dom_exception = true });
pub const innerHTML = bridge.accessor(_innerHTML, DocumentFragment.setInnerHTML, .{});
fn _innerHTML(self: *DocumentFragment, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);

View File

@@ -474,7 +474,11 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
try frame.insertAllChildrenBefore(fragment, parent, node);
}
frame.removeNode(parent, node, .{ .will_be_reconnected = false });
// A custom element callback fired during insertAllChildrenBefore may
// have already detached `node`; only remove it if it's still here.
if (node._parent == parent) {
frame.removeNode(parent, node, .{ .will_be_reconnected = false });
}
}
pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void {
@@ -485,16 +489,21 @@ pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void
pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
// Remove all existing children. Drain via firstChild(): removeNode
// fires disconnectedCallback for custom elements, which can mutate
// the child list and dangle any cached next-pointer the iterator
// would otherwise hold.
frame.domChanged();
var it = parent.childrenIterator();
while (it.next()) |child| {
while (parent.firstChild()) |child| {
frame.removeNode(parent, child, .{ .will_be_reconnected = false });
}
// Fast path: skip parsing if html is empty
if (html.len == 0) {
return;
}
// Parse and add new children
try frame.parseHtmlAsChildren(parent, html);
}
@@ -1734,21 +1743,21 @@ pub const JsApi = struct {
}
pub const namespaceURI = bridge.accessor(Element.getNamespaceURI, null, .{});
pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{ .ce_reactions = true });
pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{});
fn _innerText(self: *Element, frame: *const Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
try self.getInnerText(&buf.writer);
return buf.written();
}
pub const outerHTML = bridge.accessor(_outerHTML, Element.setOuterHTML, .{ .ce_reactions = true });
pub const outerHTML = bridge.accessor(_outerHTML, Element.setOuterHTML, .{});
fn _outerHTML(self: *Element, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
try self.getOuterHTML(&buf.writer, frame);
return buf.written();
}
pub const innerHTML = bridge.accessor(_innerHTML, Element.setInnerHTML, .{ .ce_reactions = true });
pub const innerHTML = bridge.accessor(_innerHTML, Element.setInnerHTML, .{});
fn _innerHTML(self: *Element, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.call_arena);
try self.getInnerHTML(&buf.writer, frame);
@@ -1757,24 +1766,24 @@ pub const JsApi = struct {
pub const prefix = bridge.accessor(Element._prefix, null, .{});
pub const setAttribute = bridge.function(_setAttribute, .{ .dom_exception = true, .ce_reactions = true });
pub const setAttribute = bridge.function(_setAttribute, .{ .dom_exception = true });
fn _setAttribute(self: *Element, name: String, value: js.Value, frame: *Frame) !void {
return self.setAttribute(name, .wrap(try value.toStringSlice()), frame);
}
pub const setAttributeNS = bridge.function(_setAttributeNS, .{ .dom_exception = true, .ce_reactions = true });
pub const setAttributeNS = bridge.function(_setAttributeNS, .{ .dom_exception = true });
fn _setAttributeNS(self: *Element, maybe_ns: ?[]const u8, qn: []const u8, value: js.Value, frame: *Frame) !void {
return self.setAttributeNS(maybe_ns, qn, .wrap(try value.toStringSlice()), frame);
}
pub const localName = bridge.accessor(Element.getLocalName, null, .{});
pub const id = bridge.accessor(Element.getId, Element.setId, .{ .ce_reactions = true });
pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{ .ce_reactions = true });
pub const ariaAtomic = bridge.accessor(Element.getAriaAtomic, Element.setAriaAtomic, .{ .ce_reactions = true });
pub const ariaLive = bridge.accessor(Element.getAriaLive, Element.setAriaLive, .{ .ce_reactions = true });
pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{ .ce_reactions = true });
pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{ .ce_reactions = true });
pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{ .ce_reactions = true });
pub const id = bridge.accessor(Element.getId, Element.setId, .{});
pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{});
pub const ariaAtomic = bridge.accessor(Element.getAriaAtomic, Element.setAriaAtomic, .{});
pub const ariaLive = bridge.accessor(Element.getAriaLive, Element.setAriaLive, .{});
pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{});
pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{});
pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{});
pub const dataset = bridge.accessor(Element.getDataset, null, .{});
pub const style = bridge.accessor(Element.getOrCreateStyle, null, .{});
pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{});
@@ -1783,17 +1792,17 @@ pub const JsApi = struct {
pub const getAttribute = bridge.function(Element.getAttribute, .{});
pub const getAttributeNS = bridge.function(Element.getAttributeNS, .{});
pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{});
pub const setAttributeNode = bridge.function(Element.setAttributeNode, .{ .ce_reactions = true });
pub const removeAttribute = bridge.function(Element.removeAttribute, .{ .ce_reactions = true });
pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{ .dom_exception = true, .ce_reactions = true });
pub const setAttributeNode = bridge.function(Element.setAttributeNode, .{});
pub const removeAttribute = bridge.function(Element.removeAttribute, .{});
pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{ .dom_exception = true });
pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{});
pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true, .ce_reactions = true });
pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true });
pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{});
pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{});
pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true });
pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true, .ce_reactions = true });
pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .dom_exception = true, .ce_reactions = true });
pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .dom_exception = true, .ce_reactions = true });
pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true });
pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .dom_exception = true });
pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .dom_exception = true });
const ShadowRootInit = struct {
mode: []const u8,
@@ -1801,13 +1810,13 @@ pub const JsApi = struct {
fn _attachShadow(self: *Element, init: ShadowRootInit, frame: *Frame) !*ShadowRoot {
return self.attachShadow(init.mode, 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 });
pub const remove = bridge.function(Element.remove, .{ .ce_reactions = true });
pub const append = bridge.function(Element.append, .{ .dom_exception = true, .ce_reactions = true });
pub const prepend = bridge.function(Element.prepend, .{ .dom_exception = true, .ce_reactions = true });
pub const before = bridge.function(Element.before, .{ .dom_exception = true, .ce_reactions = true });
pub const after = bridge.function(Element.after, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceChildren = bridge.function(Element.replaceChildren, .{ .dom_exception = true });
pub const replaceWith = bridge.function(Element.replaceWith, .{ .dom_exception = true });
pub const remove = bridge.function(Element.remove, .{});
pub const append = bridge.function(Element.append, .{ .dom_exception = true });
pub const prepend = bridge.function(Element.prepend, .{ .dom_exception = true });
pub const before = bridge.function(Element.before, .{ .dom_exception = true });
pub const after = bridge.function(Element.after, .{ .dom_exception = true });
pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{});
pub const lastElementChild = bridge.accessor(Element.lastElementChild, null, .{});
pub const nextElementSibling = bridge.accessor(Element.nextElementSibling, null, .{});

View File

@@ -20,9 +20,9 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const String = lp.String;
const Execution = js.Execution;
/// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData
const ImageData = @This();
@@ -55,7 +55,7 @@ pub fn init(
width: u32,
height: u32,
maybe_settings: ?ConstructorSettings,
frame: *Frame,
exec: *Execution,
) !*ImageData {
// Though arguments are unsigned long, these are capped to max. i32 on Chrome.
// https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/html/canvas/image_data.cc#L61
@@ -77,10 +77,10 @@ pub fn init(
size, overflown = @mulWithOverflow(size, 4);
if (overflown == 1) return error.IndexSizeError;
return frame._factory.create(ImageData{
return exec._factory.create(ImageData{
._width = width,
._height = height,
._data = try frame.js.local.?.createTypedArray(.uint8_clamped, size).persist(),
._data = try exec.context.local.?.createTypedArray(.uint8_clamped, size).persist(),
});
}

View File

@@ -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,
@@ -615,6 +620,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,
@@ -637,8 +658,11 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, frame: *Fra
_ = try self.insertBefore(new_child, old_child, frame);
// Special case: if we replace a node by itself, insertBefore was a noop.
if (new_child != old_child) {
// Special case: if we replace a node by itself, we don't remove it.
// insertBefore is an noop in this case.
// Re-check parent after insertBefore since callbacks (e.g. connectedCallback)
// could have already removed old_child from self.
if (new_child != old_child and old_child._parent == self) {
frame.removeNode(self, old_child, .{ .will_be_reconnected = false });
}
@@ -1139,7 +1163,7 @@ pub const JsApi = struct {
}.wrap, null, .{});
pub const nodeType = bridge.accessor(Node.getNodeType, null, .{});
pub const textContent = bridge.accessor(_textContext, Node.setTextContent, .{ .ce_reactions = true });
pub const textContent = bridge.accessor(_textContext, Node.setTextContent, .{});
fn _textContext(self: *Node, frame: *const Frame) !?[]const u8 {
// cdata and attributes can return value directly, avoiding the copy
switch (self._type) {
@@ -1161,19 +1185,19 @@ pub const JsApi = struct {
pub const previousSibling = bridge.accessor(Node.previousSibling, null, .{});
pub const parentNode = bridge.accessor(Node.parentNode, null, .{});
pub const parentElement = bridge.accessor(Node.parentElement, null, .{});
pub const appendChild = bridge.function(Node.appendChild, .{ .dom_exception = true, .ce_reactions = true });
pub const appendChild = bridge.function(Node.appendChild, .{ .dom_exception = true });
pub const childNodes = bridge.accessor(Node.childNodes, null, .{ .cache = .{ .private = "child_nodes" } });
pub const isConnected = bridge.accessor(Node.isConnected, null, .{});
pub const ownerDocument = bridge.accessor(Node.ownerDocument, null, .{});
pub const hasChildNodes = bridge.function(Node.hasChildNodes, .{});
pub const isSameNode = bridge.function(Node.isSameNode, .{});
pub const contains = bridge.function(Node.contains, .{});
pub const removeChild = bridge.function(Node.removeChild, .{ .dom_exception = true, .ce_reactions = true });
pub const nodeValue = bridge.accessor(Node.getNodeValue, Node.setNodeValue, .{ .ce_reactions = true });
pub const insertBefore = bridge.function(Node.insertBefore, .{ .dom_exception = true, .ce_reactions = true });
pub const replaceChild = bridge.function(Node.replaceChild, .{ .dom_exception = true, .ce_reactions = true });
pub const normalize = bridge.function(Node.normalize, .{ .ce_reactions = true });
pub const cloneNode = bridge.function(Node.cloneNode, .{ .dom_exception = true, .ce_reactions = true });
pub const removeChild = bridge.function(Node.removeChild, .{ .dom_exception = true });
pub const nodeValue = bridge.accessor(Node.getNodeValue, Node.setNodeValue, .{});
pub const insertBefore = bridge.function(Node.insertBefore, .{ .dom_exception = true });
pub const replaceChild = bridge.function(Node.replaceChild, .{ .dom_exception = true });
pub const normalize = bridge.function(Node.normalize, .{});
pub const cloneNode = bridge.function(Node.cloneNode, .{ .dom_exception = true });
pub const compareDocumentPosition = bridge.function(Node.compareDocumentPosition, .{});
pub const getRootNode = bridge.function(Node.getRootNode, .{});
pub const isEqualNode = bridge.function(Node.isEqualNode, .{});

View File

@@ -21,11 +21,12 @@ const std = @import("std");
const js = @import("../../js/js.zig");
const color = @import("../../color.zig");
const Frame = @import("../../Frame.zig");
const Canvas = @import("../element/html/Canvas.zig");
const ImageData = @import("../ImageData.zig");
const Execution = js.Execution;
/// This class doesn't implement a `constructor`.
/// It can be obtained with a call to `HTMLCanvasElement#getContext`.
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
@@ -41,8 +42,8 @@ pub fn getCanvas(self: *const CanvasRenderingContext2D) *Canvas {
return self._canvas;
}
pub fn getFillStyle(self: *const CanvasRenderingContext2D, frame: *Frame) ![]const u8 {
var w = std.Io.Writer.Allocating.init(frame.call_arena);
pub fn getFillStyle(self: *const CanvasRenderingContext2D, exec: *Execution) ![]const u8 {
var w = std.Io.Writer.Allocating.init(exec.call_arena);
try self._fill_style.format(&w.writer);
return w.written();
}
@@ -67,15 +68,15 @@ pub fn createImageData(
maybe_height: ?u32,
/// Can be used if width and height provided.
maybe_settings: ?ImageData.ConstructorSettings,
frame: *Frame,
exec: *Execution,
) !*ImageData {
switch (width_or_image_data) {
.width => |width| {
const height = maybe_height orelse return error.TypeError;
return ImageData.init(width, height, maybe_settings, frame);
return ImageData.init(width, height, maybe_settings, exec);
},
.image_data => |image_data| {
return ImageData.init(image_data._width, image_data._height, null, frame);
return ImageData.init(image_data._width, image_data._height, null, exec);
},
}
}
@@ -88,12 +89,12 @@ pub fn getImageData(
_: i32, // sy
sw: i32,
sh: i32,
frame: *Frame,
exec: *Execution,
) !*ImageData {
if (sw <= 0 or sh <= 0) {
return error.IndexSizeError;
}
return ImageData.init(@intCast(sw), @intCast(sh), null, frame);
return ImageData.init(@intCast(sw), @intCast(sh), null, exec);
}
pub fn save(_: *CanvasRenderingContext2D) void {}

View File

@@ -20,10 +20,11 @@ const std = @import("std");
const js = @import("../../js/js.zig");
const color = @import("../../color.zig");
const Frame = @import("../../Frame.zig");
const ImageData = @import("../ImageData.zig");
const Execution = js.Execution;
/// This class doesn't implement a `constructor`.
/// It can be obtained with a call to `OffscreenCanvas#getContext`.
/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvasRenderingContext2D
@@ -32,8 +33,8 @@ const OffscreenCanvasRenderingContext2D = @This();
/// TODO: Add support for `CanvasGradient` and `CanvasPattern`.
_fill_style: color.RGBA = color.RGBA.Named.black,
pub fn getFillStyle(self: *const OffscreenCanvasRenderingContext2D, frame: *Frame) ![]const u8 {
var w = std.Io.Writer.Allocating.init(frame.call_arena);
pub fn getFillStyle(self: *const OffscreenCanvasRenderingContext2D, exec: *Execution) ![]const u8 {
var w = std.Io.Writer.Allocating.init(exec.call_arena);
try self._fill_style.format(&w.writer);
return w.written();
}
@@ -58,15 +59,15 @@ pub fn createImageData(
maybe_height: ?u32,
/// Can be used if width and height provided.
maybe_settings: ?ImageData.ConstructorSettings,
frame: *Frame,
exec: *Execution,
) !*ImageData {
switch (width_or_image_data) {
.width => |width| {
const height = maybe_height orelse return error.TypeError;
return ImageData.init(width, height, maybe_settings, frame);
return ImageData.init(width, height, maybe_settings, exec);
},
.image_data => |image_data| {
return ImageData.init(image_data._width, image_data._height, null, frame);
return ImageData.init(image_data._width, image_data._height, null, exec);
},
}
}
@@ -79,12 +80,12 @@ pub fn getImageData(
_: i32, // sy
sw: i32,
sh: i32,
frame: *Frame,
exec: *Execution,
) !*ImageData {
if (sw <= 0 or sh <= 0) {
return error.IndexSizeError;
}
return ImageData.init(@intCast(sw), @intCast(sh), null, frame);
return ImageData.init(@intCast(sw), @intCast(sh), null, exec);
}
pub fn save(_: *OffscreenCanvasRenderingContext2D) void {}

View File

@@ -311,11 +311,11 @@ pub const JsApi = struct {
}
pub const contains = bridge.function(DOMTokenList.contains, .{ .dom_exception = true });
pub const add = bridge.function(DOMTokenList.add, .{ .dom_exception = true, .ce_reactions = true });
pub const remove = bridge.function(DOMTokenList.remove, .{ .dom_exception = true, .ce_reactions = true });
pub const toggle = bridge.function(DOMTokenList.toggle, .{ .dom_exception = true, .ce_reactions = true });
pub const replace = bridge.function(DOMTokenList.replace, .{ .dom_exception = true, .ce_reactions = true });
pub const value = bridge.accessor(DOMTokenList.getValue, DOMTokenList.setValue, .{ .ce_reactions = true });
pub const add = bridge.function(DOMTokenList.add, .{ .dom_exception = true });
pub const remove = bridge.function(DOMTokenList.remove, .{ .dom_exception = true });
pub const toggle = bridge.function(DOMTokenList.toggle, .{ .dom_exception = true });
pub const replace = bridge.function(DOMTokenList.replace, .{ .dom_exception = true });
pub const value = bridge.accessor(DOMTokenList.getValue, DOMTokenList.setValue, .{});
pub const toString = bridge.function(DOMTokenList.getValue, .{});
pub const keys = bridge.function(DOMTokenList.keys, .{});
pub const values = bridge.function(DOMTokenList.values, .{});

View File

@@ -104,7 +104,7 @@ pub const JsApi = struct {
pub const name = bridge.accessor(Attribute.getName, null, .{});
pub const localName = bridge.accessor(Attribute.getName, null, .{});
pub const value = bridge.accessor(Attribute.getValue, Attribute.setValue, .{ .ce_reactions = true });
pub const value = bridge.accessor(Attribute.getValue, Attribute.setValue, .{});
pub const namespaceURI = bridge.accessor(Attribute.getNamespaceURI, null, .{});
pub const ownerElement = bridge.accessor(Attribute.getOwnerElement, null, .{});
};
@@ -536,8 +536,8 @@ pub const NamedNodeMap = struct {
pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, null, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, .{ .null_as_undefined = true });
pub const getNamedItem = bridge.function(NamedNodeMap.getByName, .{});
pub const setNamedItem = bridge.function(NamedNodeMap.set, .{ .ce_reactions = true });
pub const removeNamedItem = bridge.function(NamedNodeMap.removeByName, .{ .ce_reactions = true });
pub const setNamedItem = bridge.function(NamedNodeMap.set, .{});
pub const removeNamedItem = bridge.function(NamedNodeMap.removeByName, .{});
pub const item = bridge.function(_item, .{});
fn _item(self: *const NamedNodeMap, index: i32, frame: *Frame) !?*Attribute {
// the bridge.indexed handles this, so if we want

View File

@@ -27,7 +27,6 @@ const Element = @import("../../Element.zig");
const Document = @import("../../Document.zig");
const HtmlElement = @import("../Html.zig");
const CustomElementDefinition = @import("../../CustomElementDefinition.zig");
const Reaction = @import("../../../CustomElementReactions.zig").Reaction;
const log = lp.log;
const String = lp.String;
@@ -46,18 +45,53 @@ pub fn asNode(self: *Custom) *Node {
return self.asElement().asNode();
}
// Reactions are queued via enqueue* and fired via fireReaction at the outer
// CEReactions boundary (set up by the JS bridge, the parser pump, etc.).
//
// Dedup happens at enqueue time: the connected/disconnected flags flip when
// we queue a reaction so that a redundant enqueue (already-in-this-state)
// is dropped, and a remove+re-insert in the same scope queues both reactions
// in order. Fire-time is unconditional.
pub fn invokeConnectedCallback(self: *Custom, frame: *Frame) void {
// Only invoke if we haven't already called it while connected
if (self._connected_callback_invoked) {
return;
}
pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, frame: *Frame) error{OutOfMemory}!void {
self._connected_callback_invoked = true;
self._disconnected_callback_invoked = false;
self.invokeCallback("connectedCallback", .{}, frame);
}
pub fn invokeDisconnectedCallback(self: *Custom, frame: *Frame) void {
// Only invoke if we haven't already called it while disconnected
if (self._disconnected_callback_invoked) {
return;
}
self._disconnected_callback_invoked = true;
self._connected_callback_invoked = false;
self.invokeCallback("disconnectedCallback", .{}, frame);
}
pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?String, new_value: ?String, namespace: ?String, frame: *Frame) void {
const definition = self._definition orelse return;
if (!definition.isAttributeObserved(name)) {
return;
}
self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame);
}
pub fn invokeAdoptedCallback(self: *Custom, old_document: *Document, new_document: *Document, frame: *Frame) void {
self.invokeCallback("adoptedCallback", .{ old_document, new_document }, frame);
}
pub fn invokeAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void {
if (element.is(Custom)) |custom| {
custom.invokeAdoptedCallback(old_document, new_document, frame);
return;
}
const definition = frame.getCustomizedBuiltInDefinition(element) orelse return;
invokeCallbackOnElement(element, definition, "adoptedCallback", .{ old_document, new_document }, frame);
}
pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, frame: *Frame) !void {
// Autonomous custom element
if (element.is(Custom)) |custom| {
// Upgrade if a definition exists but isn't yet attached
// If the element is undefined, check if a definition now exists and upgrade
if (custom._definition == null) {
const name = custom._tag_name.str();
if (frame.window._custom_elements._definitions.get(name)) |definition| {
@@ -65,31 +99,30 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E
CustomElementRegistry.upgradeCustomElement(custom, definition, frame) catch {};
return;
}
// Element is undefined and no definition exists yet — nothing to queue.
return;
}
// Dedup: skip if already queued/fired while connected.
if (custom._connected_callback_invoked) return;
custom._connected_callback_invoked = true;
custom._disconnected_callback_invoked = false;
try frame._ce_reactions.enqueueConnected(element);
if (comptime from_parser) {
// From parser, we know the element is brand new
custom._connected_callback_invoked = true;
custom.invokeCallback("connectedCallback", .{}, frame);
} else {
custom.invokeConnectedCallback(frame);
}
return;
}
// Customized built-in element - check if it actually has a definition first
if (frame.getCustomizedBuiltInDefinition(element) == null) {
return;
}
const definition = frame.getCustomizedBuiltInDefinition(element) orelse return;
if (comptime from_parser) {
// From parser, we know the element is brand new; skip the dedup check.
// From parser, we know the element is brand new, skip the tracking check
try frame._customized_builtin_connected_callback_invoked.put(
frame.arena,
element,
{},
);
} else {
// Not from parser, check if we've already invoked while connected
const gop = try frame._customized_builtin_connected_callback_invoked.getOrPut(
frame.arena,
element,
@@ -101,95 +134,43 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E
}
_ = frame._customized_builtin_disconnected_callback_invoked.remove(element);
try frame._ce_reactions.enqueueConnected(element);
invokeCallbackOnElement(element, definition, "connectedCallback", .{}, frame);
}
pub fn enqueueDisconnectedCallbackOnElement(element: *Element, frame: *Frame) void {
pub fn invokeDisconnectedCallbackOnElement(element: *Element, frame: *Frame) void {
// Autonomous custom element
if (element.is(Custom)) |custom| {
if (custom._definition == null) return;
if (custom._disconnected_callback_invoked) return;
custom._disconnected_callback_invoked = true;
custom._connected_callback_invoked = false;
frame._ce_reactions.enqueueDisconnected(element) catch |err| {
log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err });
};
custom.invokeDisconnectedCallback(frame);
return;
}
if (frame.getCustomizedBuiltInDefinition(element) == null) {
return;
}
// Customized built-in element - check if it actually has a definition first
const definition = frame.getCustomizedBuiltInDefinition(element) orelse return;
// Check if we've already invoked disconnectedCallback while disconnected
const gop = frame._customized_builtin_disconnected_callback_invoked.getOrPut(
frame.arena,
element,
) catch return;
if (gop.found_existing) return;
gop.value_ptr.* = {};
_ = frame._customized_builtin_connected_callback_invoked.remove(element);
frame._ce_reactions.enqueueDisconnected(element) catch |err| {
log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err });
};
invokeCallbackOnElement(element, definition, "disconnectedCallback", .{}, frame);
}
pub fn enqueueAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void {
pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: String, old_value: ?String, new_value: ?String, namespace: ?String, frame: *Frame) void {
// Autonomous custom element
if (element.is(Custom)) |custom| {
if (custom._definition == null) return;
} else {
if (frame.getCustomizedBuiltInDefinition(element) == null) return;
custom.invokeAttributeChangedCallback(name, old_value, new_value, namespace, frame);
return;
}
frame._ce_reactions.enqueueAdopted(element, old_document, new_document) catch |err| {
log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err });
};
}
pub fn enqueueAttributeChangedCallbackOnElement(element: *Element, name: String, old_value: ?String, new_value: ?String, namespace: ?String, frame: *Frame) void {
if (element.is(Custom)) |custom| {
const definition = custom._definition orelse return;
if (!definition.isAttributeObserved(name)) return;
} else {
const definition = frame.getCustomizedBuiltInDefinition(element) orelse return;
if (!definition.isAttributeObserved(name)) return;
}
frame._ce_reactions.enqueueAttributeChanged(element, name, old_value, new_value, namespace) catch |err| {
log.warn(.bug, "ce_reactions enqueue fail", .{ .err = err });
};
}
// Called by CustomElementReactions.popAndInvoke for each queued reaction.
// Filtering already happened at enqueue time, so just fire unconditionally.
pub fn fireReaction(reaction: Reaction, frame: *Frame) void {
switch (reaction) {
.connected => |el| {
if (el.is(Custom)) |custom| {
custom.invokeCallback("connectedCallback", .{}, frame);
} else if (frame.getCustomizedBuiltInDefinition(el)) |definition| {
invokeCallbackOnElement(el, definition, "connectedCallback", .{}, frame);
}
},
.disconnected => |el| {
if (el.is(Custom)) |custom| {
custom.invokeCallback("disconnectedCallback", .{}, frame);
} else if (frame.getCustomizedBuiltInDefinition(el)) |definition| {
invokeCallbackOnElement(el, definition, "disconnectedCallback", .{}, frame);
}
},
.adopted => |a| {
if (a.element.is(Custom)) |custom| {
custom.invokeCallback("adoptedCallback", .{ a.old_document, a.new_document }, frame);
} else if (frame.getCustomizedBuiltInDefinition(a.element)) |definition| {
invokeCallbackOnElement(a.element, definition, "adoptedCallback", .{ a.old_document, a.new_document }, frame);
}
},
.attribute_changed => |a| {
if (a.element.is(Custom)) |custom| {
custom.invokeCallback("attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame);
} else if (frame.getCustomizedBuiltInDefinition(a.element)) |definition| {
invokeCallbackOnElement(a.element, definition, "attributeChangedCallback", .{ a.name, a.old_value, a.new_value, a.namespace }, frame);
}
},
}
// Customized built-in element - check if attribute is observed
const definition = frame.getCustomizedBuiltInDefinition(element) orelse return;
if (!definition.isAttributeObserved(name)) return;
invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame);
}
fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, frame: *Frame) void {