diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 850c8070..68f5e489 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -550,6 +550,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/Event.zig"),
@import("../webapi/event/CustomEvent.zig"),
@import("../webapi/event/ErrorEvent.zig"),
+ @import("../webapi/event/MessageEvent.zig"),
@import("../webapi/event/ProgressEvent.zig"),
@import("../webapi/EventTarget.zig"),
@import("../webapi/Location.zig"),
diff --git a/src/browser/tests/custom_elements/attribute_changed.html b/src/browser/tests/custom_elements/attribute_changed.html
index f94de7f3..2ebbf99e 100644
--- a/src/browser/tests/custom_elements/attribute_changed.html
+++ b/src/browser/tests/custom_elements/attribute_changed.html
@@ -122,6 +122,10 @@
testing.expectEqual(0, callbackCalls.length);
customElements.define('upgrade-attr-element', UpgradeAttrElement);
+ testing.expectEqual(0, callbackCalls.length);
+
+ document.body.appendChild(el);
+
testing.expectEqual(1, callbackCalls.length);
testing.expectEqual('existing', callbackCalls[0].name);
testing.expectEqual(null, callbackCalls[0].oldValue);
diff --git a/src/browser/tests/custom_elements/upgrade.html b/src/browser/tests/custom_elements/upgrade.html
index 73f1367c..3827ca82 100644
--- a/src/browser/tests/custom_elements/upgrade.html
+++ b/src/browser/tests/custom_elements/upgrade.html
@@ -181,4 +181,175 @@
testing.expectEqual(null, attributeChangedCalls[1].oldValue);
testing.expectEqual('world', attributeChangedCalls[1].newValue);
}
+
+{
+ let attributeChangedCalls = [];
+ let connectedCalls = 0;
+
+ class DetachedWithAttrs extends HTMLElement {
+ static get observedAttributes() {
+ return ['foo'];
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ attributeChangedCalls.push({ name, oldValue, newValue });
+ }
+
+ connectedCallback() {
+ connectedCalls++;
+ }
+ }
+
+ const container = document.createElement('div');
+ container.innerHTML = '';
+
+ testing.expectEqual(0, attributeChangedCalls.length);
+
+ customElements.define('detached-with-attrs', DetachedWithAttrs);
+
+ testing.expectEqual(0, attributeChangedCalls.length);
+ testing.expectEqual(0, connectedCalls);
+
+ document.body.appendChild(container);
+
+ testing.expectEqual(1, attributeChangedCalls.length);
+ testing.expectEqual('foo', attributeChangedCalls[0].name);
+ testing.expectEqual(null, attributeChangedCalls[0].oldValue);
+ testing.expectEqual('bar', attributeChangedCalls[0].newValue);
+ testing.expectEqual(1, connectedCalls);
+}
+
+{
+ let attributeChangedCalls = [];
+ let constructorCalled = 0;
+
+ class ManualUpgradeWithAttrs extends HTMLElement {
+ static get observedAttributes() {
+ return ['x', 'y'];
+ }
+
+ constructor() {
+ super();
+ constructorCalled++;
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ attributeChangedCalls.push({ name, oldValue, newValue });
+ }
+ }
+
+ customElements.define('manual-upgrade-with-attrs', ManualUpgradeWithAttrs);
+
+ const container = document.createElement('div');
+ container.innerHTML = '';
+
+ testing.expectEqual(1, constructorCalled);
+ testing.expectEqual(2, attributeChangedCalls.length);
+
+ const elem = container.querySelector('manual-upgrade-with-attrs');
+ elem.setAttribute('z', '3');
+
+ customElements.upgrade(container);
+
+ testing.expectEqual(1, constructorCalled);
+ testing.expectEqual(2, attributeChangedCalls.length);
+}
+
+{
+ let attributeChangedCalls = [];
+
+ class MixedAttrs extends HTMLElement {
+ static get observedAttributes() {
+ return ['watched'];
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ attributeChangedCalls.push({ name, oldValue, newValue });
+ }
+ }
+
+ const container = document.createElement('div');
+ container.innerHTML = '';
+ document.body.appendChild(container);
+
+ testing.expectEqual(0, attributeChangedCalls.length);
+
+ customElements.define('mixed-attrs', MixedAttrs);
+
+ testing.expectEqual(1, attributeChangedCalls.length);
+ testing.expectEqual('watched', attributeChangedCalls[0].name);
+ testing.expectEqual('yes', attributeChangedCalls[0].newValue);
+}
+
+{
+ let attributeChangedCalls = [];
+
+ class EmptyAttr extends HTMLElement {
+ static get observedAttributes() {
+ return ['empty', 'non-empty'];
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ attributeChangedCalls.push({ name, oldValue, newValue });
+ }
+ }
+
+ const container = document.createElement('div');
+ container.innerHTML = '';
+ document.body.appendChild(container);
+
+ customElements.define('empty-attr', EmptyAttr);
+
+ testing.expectEqual(2, attributeChangedCalls.length);
+ testing.expectEqual('empty', attributeChangedCalls[0].name);
+ testing.expectEqual('', attributeChangedCalls[0].newValue);
+ testing.expectEqual('non-empty', attributeChangedCalls[1].name);
+ testing.expectEqual('value', attributeChangedCalls[1].newValue);
+}
+
+{
+ let parentCalls = [];
+ let childCalls = [];
+
+ class NestedParent extends HTMLElement {
+ static get observedAttributes() {
+ return ['parent-attr'];
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ parentCalls.push({ name, oldValue, newValue });
+ }
+ }
+
+ class NestedChild extends HTMLElement {
+ static get observedAttributes() {
+ return ['child-attr'];
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ childCalls.push({ name, oldValue, newValue });
+ }
+ }
+
+ const container = document.createElement('div');
+ container.innerHTML = '';
+ document.body.appendChild(container);
+
+ testing.expectEqual(0, parentCalls.length);
+ testing.expectEqual(0, childCalls.length);
+
+ customElements.define('nested-parent', NestedParent);
+
+ testing.expectEqual(1, parentCalls.length);
+ testing.expectEqual('parent-attr', parentCalls[0].name);
+ testing.expectEqual('p', parentCalls[0].newValue);
+ testing.expectEqual(0, childCalls.length);
+
+ customElements.define('nested-child', NestedChild);
+
+ testing.expectEqual(1, parentCalls.length);
+ testing.expectEqual(1, childCalls.length);
+ testing.expectEqual('child-attr', childCalls[0].name);
+ testing.expectEqual('c', childCalls[0].newValue);
+}
diff --git a/src/browser/tests/event/message.html b/src/browser/tests/event/message.html
new file mode 100644
index 00000000..079f9c7a
--- /dev/null
+++ b/src/browser/tests/event/message.html
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/event/message_multiple_listeners.html b/src/browser/tests/event/message_multiple_listeners.html
new file mode 100644
index 00000000..36f13eb2
--- /dev/null
+++ b/src/browser/tests/event/message_multiple_listeners.html
@@ -0,0 +1,19 @@
+
+
+
+
diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig
index 318b80e6..6fc67621 100644
--- a/src/browser/webapi/CustomElementRegistry.zig
+++ b/src/browser/webapi/CustomElementRegistry.zig
@@ -92,6 +92,11 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu
continue;
}
+ if (!custom.asElement().asNode().isConnected()) {
+ idx += 1;
+ continue;
+ }
+
upgradeCustomElement(custom, definition, page) catch {
_ = page._undefined_custom_elements.swapRemove(idx);
continue;
@@ -134,7 +139,7 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, page: *Page)
try upgradeCustomElement(custom, definition, page);
}
-fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinition, page: *Page) !void {
+pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinition, page: *Page) !void {
custom._definition = definition;
// Reset callback flags since this is a fresh upgrade
diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig
index b11a83fa..56461eb5 100644
--- a/src/browser/webapi/Event.zig
+++ b/src/browser/webapi/Event.zig
@@ -49,9 +49,10 @@ pub const EventPhase = enum(u8) {
pub const Type = union(enum) {
generic,
- progress_event: *@import("event/ProgressEvent.zig"),
error_event: *@import("event/ErrorEvent.zig"),
custom_event: *@import("event/CustomEvent.zig"),
+ message_event: *@import("event/MessageEvent.zig"),
+ progress_event: *@import("event/ProgressEvent.zig"),
};
const Options = struct {
diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig
index f025d1ed..ba3683b6 100644
--- a/src/browser/webapi/Window.zig
+++ b/src/browser/webapi/Window.zig
@@ -34,6 +34,7 @@ const Location = @import("Location.zig");
const Fetch = @import("net/Fetch.zig");
const EventTarget = @import("EventTarget.zig");
const ErrorEvent = @import("event/ErrorEvent.zig");
+const MessageEvent = @import("event/MessageEvent.zig");
const MediaQueryList = @import("css/MediaQueryList.zig");
const storage = @import("storage/storage.zig");
const Element = @import("Element.zig");
@@ -255,6 +256,28 @@ pub fn getComputedStyle(_: *const Window, _: *Element, page: *Page) !*CSSStyleDe
return CSSStyleDeclaration.init(null, page);
}
+pub fn postMessage(self: *Window, message: js.Object, target_origin: ?[]const u8, page: *Page) !void {
+ // For now, we ignore targetOrigin checking and just dispatch the message
+ // In a full implementation, we would validate the origin
+ _ = target_origin;
+
+ // postMessage queues a task (not a microtask), so use the scheduler
+ const origin = try self._location.getOrigin(page);
+ const callback = try page._factory.create(PostMessageCallback{
+ .window = self,
+ .message = try message.persist() ,
+ .origin = try page.arena.dupe(u8, origin),
+ .page = page,
+ });
+ errdefer page._factory.destroy(callback);
+
+
+ try page.scheduler.add(callback, PostMessageCallback.run, 0, .{
+ .name = "postMessage",
+ .low_priority = false,
+ });
+}
+
pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
const encoded_len = std.base64.standard.Encoder.calcSize(input.len);
const encoded = try page.call_arena.alloc(u8, encoded_len);
@@ -268,6 +291,26 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
return decoded;
}
+pub fn getLength(_: *const Window) u32 {
+ return 0;
+}
+
+pub fn getInnerWidth(_: *const Window) u32 {
+ return 1920;
+}
+
+pub fn getInnerHeight(_: *const Window) u32 {
+ return 1080;
+}
+
+pub fn getScrollX(_: *const Window) u32 {
+ return 0;
+}
+
+pub fn getScrollY(_: *const Window) u32 {
+ return 0;
+}
+
const ScheduleOpts = struct {
repeat: bool,
params: []js.Object,
@@ -376,6 +419,35 @@ const ScheduleCallback = struct {
}
};
+const PostMessageCallback = struct {
+ window: *Window,
+ message: js.Object,
+ origin: []const u8,
+ page: *Page,
+
+ fn deinit(self: *PostMessageCallback) void {
+ self.page._factory.destroy(self);
+ }
+
+ fn run(ctx: *anyopaque) !?u32 {
+ const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
+ defer self.deinit();
+
+ const message_event = try MessageEvent.init("message", .{
+ .data = self.message,
+ .origin = self.origin,
+ .source = self.window,
+ .bubbles = false,
+ .cancelable = false,
+ }, self.page);
+
+ const event = message_event.asEvent();
+ try self.page._event_manager.dispatch(self.window.asEventTarget(), event);
+
+ return null;
+ }
+};
+
pub const JsApi = struct {
pub const bridge = js.Bridge(Window);
@@ -415,27 +487,19 @@ pub const JsApi = struct {
pub const requestAnimationFrame = bridge.function(Window.requestAnimationFrame, .{});
pub const cancelAnimationFrame = bridge.function(Window.cancelAnimationFrame, .{});
pub const matchMedia = bridge.function(Window.matchMedia, .{});
+ pub const postMessage = bridge.function(Window.postMessage, .{});
pub const btoa = bridge.function(Window.btoa, .{});
pub const atob = bridge.function(Window.atob, .{});
pub const reportError = bridge.function(Window.reportError, .{});
pub const frames = bridge.accessor(Window.getWindow, null, .{ .cache = "frames" });
- pub const length = bridge.accessor(struct {
- fn wrap(_: *const Window) u32 {
- return 0;
- }
- }.wrap, null, .{ .cache = "length" });
-
- pub const innerWidth = bridge.accessor(struct {
- fn wrap(_: *const Window) u32 {
- return 1920;
- }
- }.wrap, null, .{ .cache = "innerWidth" });
- pub const innerHeight = bridge.accessor(struct {
- fn wrap(_: *const Window) u32 {
- return 1080;
- }
- }.wrap, null, .{ .cache = "innerHeight" });
pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
+ pub const length = bridge.accessor(Window.getLength, null, .{ .cache = "length" });
+ pub const innerWidth = bridge.accessor(Window.getInnerWidth, null, .{ .cache = "innerWidth" });
+ pub const innerHeight = bridge.accessor(Window.getInnerHeight, null, .{ .cache = "innerHeight" });
+ pub const scrollX = bridge.accessor(Window.getScrollX, null, .{ .cache = "scrollX" });
+ pub const scrollY = bridge.accessor(Window.getScrollY, null, .{ .cache = "scrollY" });
+ pub const pageXOffset = bridge.accessor(Window.getScrollX, null, .{ .cache = "pageXOffset" });
+ pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{ .cache = "pageYOffset" });
};
const testing = @import("../../testing.zig");
diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig
index a8c95d5c..3fc9071f 100644
--- a/src/browser/webapi/element/html/Custom.zig
+++ b/src/browser/webapi/element/html/Custom.zig
@@ -73,6 +73,16 @@ pub fn invokeAttributeChangedCallback(self: *Custom, name: []const u8, old_value
pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, page: *Page) !void {
// Autonomous custom element
if (element.is(Custom)) |custom| {
+ // If the element is undefined, check if a definition now exists and upgrade
+ if (custom._definition == null) {
+ const name = custom._tag_name.str();
+ if (page.window._custom_elements._definitions.get(name)) |definition| {
+ const CustomElementRegistry = @import("../../CustomElementRegistry.zig");
+ CustomElementRegistry.upgradeCustomElement(custom, definition, page) catch {};
+ return;
+ }
+ }
+
if (comptime from_parser) {
// From parser, we know the element is brand new
custom._connected_callback_invoked = true;
diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig
new file mode 100644
index 00000000..ed59bb2f
--- /dev/null
+++ b/src/browser/webapi/event/MessageEvent.zig
@@ -0,0 +1,90 @@
+// Copyright (C) 2023-2025 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 .
+
+const js = @import("../../js/js.zig");
+
+const Page = @import("../../Page.zig");
+const Event = @import("../Event.zig");
+const Window = @import("../Window.zig");
+
+const MessageEvent = @This();
+
+_proto: *Event,
+_data: ?js.Object = null,
+_origin: []const u8 = "",
+_source: ?*Window = null,
+
+pub const InitOptions = struct {
+ data: ?js.Object = null,
+ origin: ?[]const u8 = null,
+ source: ?*Window = null,
+ bubbles: bool = false,
+ cancelable: bool = false,
+};
+
+pub fn init(typ: []const u8, opts_: ?InitOptions, page: *Page) !*MessageEvent {
+ const opts = opts_ orelse InitOptions{};
+
+ const event = try page._factory.event(typ, MessageEvent{
+ ._proto = undefined,
+ ._data = if (opts.data) |d| try d.persist() else null,
+ ._origin = if (opts.origin) |str| try page.arena.dupe(u8, str) else "",
+ ._source = opts.source,
+ });
+
+ event._proto._bubbles = opts.bubbles;
+ event._proto._cancelable = opts.cancelable;
+
+ return event;
+}
+
+pub fn asEvent(self: *MessageEvent) *Event {
+ return self._proto;
+}
+
+pub fn getData(self: *const MessageEvent) ?js.Object {
+ return self._data;
+}
+
+pub fn getOrigin(self: *const MessageEvent) []const u8 {
+ return self._origin;
+}
+
+pub fn getSource(self: *const MessageEvent) ?*Window {
+ return self._source;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(MessageEvent);
+
+ pub const Meta = struct {
+ pub const name = "MessageEvent";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(MessageEvent.init, .{});
+ pub const data = bridge.accessor(MessageEvent.getData, null, .{});
+ pub const origin = bridge.accessor(MessageEvent.getOrigin, null, .{});
+ pub const source = bridge.accessor(MessageEvent.getSource, null, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: MessageEvent" {
+ try testing.htmlRunner("event/message.html", .{});
+}