Window.scrollX/Y, postMessage, more custom element edge cases

This commit is contained in:
Karl Seguin
2025-11-28 18:01:41 +08:00
parent 833a33678c
commit 8858f889b4
10 changed files with 553 additions and 18 deletions

View File

@@ -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"),

View File

@@ -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);

View File

@@ -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 = '<detached-with-attrs foo="bar"></detached-with-attrs>';
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 = '<manual-upgrade-with-attrs x="1" y="2"></manual-upgrade-with-attrs>';
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 = '<mixed-attrs watched="yes" ignored="no" also-ignored="maybe"></mixed-attrs>';
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 = '<empty-attr empty="" non-empty="value"></empty-attr>';
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 = '<nested-parent parent-attr="p"><nested-child child-attr="c"></nested-child></nested-parent>';
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);
}
</script>

View File

@@ -0,0 +1,170 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=messageEventConstructor>
{
const evt = new MessageEvent('message', {
data: { foo: 'bar', count: 42 },
origin: 'https://example.com',
source: window
});
testing.expectEqual('message', evt.type);
testing.expectEqual({ foo: 'bar', count: 42 }, evt.data);
testing.expectEqual('https://example.com', evt.origin);
testing.expectEqual(window, evt.source);
testing.expectEqual(false, evt.bubbles);
testing.expectEqual(false, evt.cancelable);
}
</script>
<script id=messageEventWithString>
{
const evt2 = new MessageEvent('message', {
data: 'Hello, World!',
origin: 'https://test.com'
});
testing.expectEqual('Hello, World!', evt2.data);
testing.expectEqual('https://test.com', evt2.origin);
}
</script>
<script id=messageEventDefaults>
{
const evt3 = new MessageEvent('message');
testing.expectEqual('', evt3.origin);
testing.expectEqual(false, evt3.bubbles);
testing.expectEqual(false, evt3.cancelable);
}
</script>
<script id=messageEventInheritance>
{
const evt4 = new MessageEvent('custom');
testing.expectEqual('custom', evt4.type);
testing.expectEqual(Event.NONE, evt4.eventPhase);
testing.expectEqual(false, evt4.defaultPrevented);
}
</script>
<script id=postMessageBasic>
{
let receivedEvent = null;
const handler = (e) => {
receivedEvent = e;
};
window.addEventListener('message', handler, { once: true });
window.postMessage('test data', '*');
testing.eventually(() => {
testing.expectEqual('test data', receivedEvent.data);
testing.expectEqual(window, receivedEvent.source);
testing.expectEqual('message', receivedEvent.type);
});
}
</script>
<script id=postMessageWithObject>
{
let receivedData = null;
const handler = (e) => {
receivedData = e.data;
};
window.addEventListener('message', handler, { once: true });
const testObj = { type: 'test', value: 123, nested: { key: 'value' } };
window.postMessage(testObj, '*');
testing.eventually(() => {
testing.expectEqual(testObj, receivedData);
});
}
</script>
<script id=messageEventWithBubbles>
{
const evt = new MessageEvent('message', {
data: 'test',
bubbles: true,
cancelable: true
});
testing.expectEqual(true, evt.bubbles);
testing.expectEqual(true, evt.cancelable);
}
</script>
<script id=postMessageWithNumber>
{
let received = null;
const handler = (e) => {
received = e.data;
};
window.addEventListener('message', handler, { once: true });
window.postMessage(42, '*');
testing.eventually(() => {
testing.expectEqual(42, received);
});
}
</script>
<script id=postMessageWithArray>
{
let received = null;
const handler = (e) => {
received = e.data;
};
window.addEventListener('message', handler, { once: true });
const arr = [1, 2, 3, 'test'];
window.postMessage(arr, '*');
testing.eventually(() => {
testing.expectEqual(arr, received);
});
}
</script>
<script id=postMessageWithNull>
{
let received = undefined;
const handler = (e) => {
received = e.data;
};
window.addEventListener('message', handler, { once: true });
window.postMessage(null, '*');
testing.eventually(() => {
testing.expectEqual(null, received);
});
}
</script>
<script id=messageEventOriginFromLocation>
{
let receivedOrigin = null;
const handler = (e) => {
receivedOrigin = e.origin;
};
window.addEventListener('message', handler, { once: true });
window.postMessage('test', '*');
testing.eventually(() => {
testing.expectEqual('http://127.0.0.1:9582', receivedOrigin);
});
}
</script>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=postMessageMultipleListeners>
{
let count = 0;
const handler1 = () => { count++; };
const handler2 = () => { count++; };
window.addEventListener('message', handler1, { once: true });
window.addEventListener('message', handler2, { once: true });
window.postMessage('trigger', '*');
testing.eventually(() => {
testing.expectEqual(2, count);
});
}
</script>

View File

@@ -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

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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;

View File

@@ -0,0 +1,90 @@
// Copyright (C) 2023-2025 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/>.
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", .{});
}