From 8858f889b4066104947ba1d376af03f5018176b7 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 28 Nov 2025 18:01:41 +0800 Subject: [PATCH] Window.scrollX/Y, postMessage, more custom element edge cases --- src/browser/js/bridge.zig | 1 + .../custom_elements/attribute_changed.html | 4 + .../tests/custom_elements/upgrade.html | 171 ++++++++++++++++++ src/browser/tests/event/message.html | 170 +++++++++++++++++ .../event/message_multiple_listeners.html | 19 ++ src/browser/webapi/CustomElementRegistry.zig | 7 +- src/browser/webapi/Event.zig | 3 +- src/browser/webapi/Window.zig | 96 ++++++++-- src/browser/webapi/element/html/Custom.zig | 10 + src/browser/webapi/event/MessageEvent.zig | 90 +++++++++ 10 files changed, 553 insertions(+), 18 deletions(-) create mode 100644 src/browser/tests/event/message.html create mode 100644 src/browser/tests/event/message_multiple_listeners.html create mode 100644 src/browser/webapi/event/MessageEvent.zig 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", .{}); +}