diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index cd3999e8..e9ccde0b 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.4.0' + default: 'v0.4.1' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index fd872c45..33501041 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.0 +ARG ZIG_V8=v0.4.1 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index c89f08dc..16bd2378 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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.0.tar.gz", - .hash = "v8-0.0.0-xddH61yIBAD04dV4CHW0qIFiqbOGvkN_-amGdmgbQ3dU", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.1.tar.gz", + .hash = "v8-0.0.0-xddH672HBAA1hQIa2Uv4mzs_qHC9-Py-M5ssqSSVhWtK", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index dedb522e..bb5cbf22 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1667,11 +1667,17 @@ pub fn setNodeOwnerDocument(self: *Frame, node: *Node, owner: *Document) !void { } // Recursively sets the owner document for a node and all its descendants -pub fn adoptNodeTree(self: *Frame, node: *Node, new_owner: *Document) !void { +pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: *Document) !void { try self.setNodeOwnerDocument(node, 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); + } + var it = node.childrenIterator(); while (it.next()) |child| { - try self.adoptNodeTree(child, new_owner); + try self.adoptNodeTree(child, old_owner, new_owner); } } @@ -2373,6 +2379,7 @@ pub fn createElementNS(self: *Frame, namespace: Element.Namespace, name: []const attr._name, null, // old_value is null for initial attributes attr._value, + null, self, ); } @@ -2469,6 +2476,35 @@ fn populateElementAttributes(self: *Frame, element: *Element, list: anytype) !vo } } +// Called when `new MyElement()` is invoked directly in JS (not via the +// customElements.define/upgrade path). `new_target` is the constructor +// function that was used with `new`. We find the matching definition in the +// registry by function identity and allocate a detached Custom element with +// the registered tag name. +pub fn constructCustomElement(self: *Frame, new_target: JS.Function) !*Element { + var it = self.window._custom_elements._definitions.iterator(); + const definition = while (it.next()) |entry| { + if (entry.value_ptr.*.constructor.isEqual(new_target)) { + break entry.value_ptr.*; + } + } else return error.IllegalConstructor; + + // Customized built-ins (`class Foo extends HTMLDivElement`, etc.) would + // need to allocate the extended HTML type rather than Custom. Not yet + // supported via direct `new` — upgrade path still works for those. + if (definition.isCustomizedBuiltIn()) { + return error.IllegalConstructor; + } + + const tag_name = try String.init(self.arena, definition.name, .{}); + const node = try self.createHtmlElementT(Element.Html.Custom, .html, @as(?*Element.Attribute.List, null), .{ + ._proto = undefined, + ._tag_name = tag_name, + ._definition = definition, + }); + return node.as(Element); +} + pub fn createTextNode(self: *Frame, text: []const u8) !*Node { const cd = try self._factory.node(CData{ ._proto = undefined, @@ -2914,7 +2950,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No } if (opts.child_already_connected and !opts.adopting_to_new_document) { - // The child is already connected in the same document, we don't have to reconnect it + // The child is already connected in the same document, we don't have to reconnect it. + // On cross-document adoption the child has already fired + // disconnectedCallback against the old tree and must re-fire + // connectedCallback for the new tree, so we fall through. return; } @@ -2932,7 +2971,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No // Only invoke connectedCallback if the root child is transitioning from // disconnected to connected. When that happens, all descendants should also // get connectedCallback invoked (they're becoming connected as a group). - const should_invoke_connected = parent_is_connected and !opts.child_already_connected; + // Cross-document adoption also counts as a transition: the element fired + // disconnectedCallback against the old tree during removeNode and must + // now fire connectedCallback against the new tree. + const should_invoke_connected = parent_is_connected and (!opts.child_already_connected or opts.adopting_to_new_document); var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{}); while (tw.next()) |el| { @@ -2951,7 +2993,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.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, 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) { @@ -2977,7 +3019,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.invokeAttributeChangedCallbackOnElement(element, name, old_value, 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) { diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 0cbf9e11..2b29c036 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -110,6 +110,10 @@ pub const CallOpts = struct { dom_exception: bool = false, null_as_undefined: bool = false, as_typed_array: bool = false, + // Constructor-only. When true, `new.target` is pulled from the + // FunctionCallbackInfo and passed as the first argument to the Zig + // function (as a js.Function). See bridge.Constructor.Opts. + new_target: bool = false, }; pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void { @@ -126,15 +130,20 @@ pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *cons return; } - self._constructor(func, info) catch |err| { + self._constructor(func, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); }; } -fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void { +fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void { const F = @TypeOf(func); const local = &self.local; - const args = try getArgs(F, 0, local, info); + const offset: comptime_int = if (opts.new_target) 1 else 0; + var args = try getArgs(F, offset, local, info); + if (comptime opts.new_target) { + const new_target_handle = v8.v8__FunctionCallbackInfo__NewTarget(info.handle).?; + @field(args, "0") = js.Function{ .local = local, .handle = @ptrCast(new_target_handle) }; + } const res = @call(.auto, func, args); const ReturnType = @typeInfo(F).@"fn".return_type orelse { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 00bcc713..73ddccab 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -111,6 +111,10 @@ pub const Constructor = struct { const Opts = struct { dom_exception: bool = false, + // When true, the constructor function receives `new.target` (as a + // js.Function) as its first parameter. Used by HTMLElement to support + // direct instantiation of custom elements via `new MyElement()`. + new_target: bool = false, }; fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Constructor { @@ -125,6 +129,7 @@ pub const Constructor = struct { caller.constructor(T, func, handle.?, .{ .dom_exception = opts.dom_exception, + .new_target = opts.new_target, }); } }.wrap }; diff --git a/src/browser/tests/custom_elements/adopted.html b/src/browser/tests/custom_elements/adopted.html new file mode 100644 index 00000000..80dbc72d --- /dev/null +++ b/src/browser/tests/custom_elements/adopted.html @@ -0,0 +1,48 @@ + +
+ + + diff --git a/src/browser/tests/custom_elements/constructor.html b/src/browser/tests/custom_elements/constructor.html index c99639a6..2364c20d 100644 --- a/src/browser/tests/custom_elements/constructor.html +++ b/src/browser/tests/custom_elements/constructor.html @@ -52,6 +52,40 @@ const el = document.createElement('no-constructor-element'); testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName); } + +{ + // Direct instantiation: `new MyElement()` should work for a registered + // autonomous custom element. + class DirectInstantiation extends HTMLElement { + constructor() { + super(); + this.init = 'direct'; + } + } + customElements.define('direct-instantiation', DirectInstantiation); + + const el = new DirectInstantiation(); + testing.expectEqual(true, el instanceof DirectInstantiation); + testing.expectEqual(true, el instanceof HTMLElement); + testing.expectEqual('direct-instantiation', el.localName); + testing.expectEqual('DIRECT-INSTANTIATION', el.tagName); + testing.expectEqual('direct', el.init); +} + +{ + // `new HTMLElement()` directly is illegal (no registered constructor). + let threw = false; + try { new HTMLElement(); } catch (e) { threw = true; } + testing.expectEqual(true, threw); +} + +{ + // Unregistered subclass of HTMLElement is also illegal. + class Unregistered extends HTMLElement {} + let threw = false; + try { new Unregistered(); } catch (e) { threw = true; } + testing.expectEqual(true, threw); +} diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 2fb7bdbf..ecd6c546 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -195,7 +195,7 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio while (attr_it.next()) |attr| { const name = attr._name; if (definition.isAttributeObserved(name)) { - custom.invokeAttributeChangedCallback(name, null, attr._value, frame); + custom.invokeAttributeChangedCallback(name, null, attr._value, null, frame); } } diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 593d9f2b..1c69fae4 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -241,12 +241,16 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node { if (child._parent) |parent| { // we can signal removeNode that the child will remain connected // (when it's appended to self) so that it can be a bit more efficient. - frame.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() }); + // But on cross-document moves the child must fully disconnect from the + // source document (firing disconnectedCallback) before adoption. + frame.removeNode(parent, child, .{ + .will_be_reconnected = self.isConnected() and !adopting_to_new_document, + }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { - try frame.adoptNodeTree(child, parent_owner); + try frame.adoptNodeTree(child, child_owner.?, parent_owner); } try frame.appendNode(self, child, .{ @@ -591,14 +595,14 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner; frame.domChanged(); - const will_be_reconnected = self.isConnected(); + 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 }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { - try frame.adoptNodeTree(new_node, parent_owner); + try frame.adoptNodeTree(new_node, child_owner.?, parent_owner); } try frame.insertNodeRelative( diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 4def3186..58d63f63 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -103,10 +103,17 @@ const HtmlElement = @This(); _type: Type, _proto: *Element, -// Special constructor for custom elements -pub fn construct(frame: *Frame) !*Element { - const node = frame._upgrading_element orelse return error.IllegalConstructor; - return node.is(Element) orelse return error.IllegalConstructor; +// Special constructor for custom elements. +// Two paths: +// - Upgrade path: customElements.define / createElement / upgrade set +// `_upgrading_element` before calling newInstance, and we just return it. +// - Direct path: `new MyElement()` from user code. `new.target` tells us +// which custom element class was invoked; look it up in the registry. +pub fn construct(new_target: js.Function, frame: *Frame) !*Element { + if (frame._upgrading_element) |node| { + return node.is(Element) orelse return error.IllegalConstructor; + } + return frame.constructCustomElement(new_target); } pub const Type = union(enum) { @@ -286,42 +293,15 @@ pub fn insertAdjacentHTML( html: []const u8, frame: *Frame, ) !void { - - // Create a new HTMLDocument. - const doc = try frame._factory.document(@import("../HTMLDocument.zig"){ - ._proto = undefined, - }); - const doc_node = doc.asNode(); - - const arena = try frame.getArena(.medium, "HTML.insertAdjacentHTML"); - defer frame.releaseArena(arena); - - const Parser = @import("../../parser/Parser.zig"); - var parser = Parser.init(arena, doc_node, frame); - parser.parse(html); - - // Check if there's parsing error. - if (parser.err) |_| { - return error.Invalid; - } - - // The parser wraps content in a document structure: - // - Typical: ...... - // - Head-only: (no body) - // - Empty/comments: May have no element at all - const html_node = doc_node.firstChild() orelse return; + const DocumentFragment = @import("../DocumentFragment.zig"); + const fragment = (try DocumentFragment.init(frame)).asNode(); + try frame.parseHtmlAsChildren(fragment, html); const target_node, const prev_node = try self.asElement().asNode().findAdjacentNodes(position); - // Iterate through all children of (typically and/or ) - // and insert their children (not the containers themselves) into the target. - // This handles both body content AND head-only elements like ,