From 7819ee50fa19e4b5c9ec645519f60f00ad633777 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sun, 26 Apr 2026 09:04:33 +0800 Subject: [PATCH 1/9] Add and default to Blackhole storage Tweak sqlite build to omit more features, hoping to shrink the size when sqlite is used. --- build.zig | 31 +++++++++++++++++++++++++------ src/Config.zig | 4 ++-- src/storage/Blackhole.zig | 24 ++++++++++++++++++++++++ src/storage/Storage.zig | 6 +++++- src/storage/sqlite/Sqlite.zig | 9 ++++++--- src/storage/sqlite/migrations.zig | 4 ++-- 6 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 src/storage/Blackhole.zig diff --git a/build.zig b/build.zig index de10273c..d1ecf1c3 100644 --- a/build.zig +++ b/build.zig @@ -284,23 +284,42 @@ fn linkSqlite(b: *Build, mod: *Build.Module, enable_csan: ?std.zig.SanitizeC, is lib.root_module.sanitize_thread = is_tsan; const macros = [_]struct { []const u8, []const u8 }{ - .{ "SQLITE_DQS", "0" }, + .{ "SQLITE_DEFAULT_FILE_PERMISSIONS", "0600" }, + .{ "SQLITE_DEFAULT_MEMSTATUS", "0" }, .{ "SQLITE_DEFAULT_WAL_SYNCHRONOUS", "1" }, - .{ "SQLITE_USE_ALLOCA", "1" }, - .{ "SQLITE_THREADSAFE", "1" }, - .{ "SQLITE_TEMP_STORE", "3" }, + .{ "SQLITE_DQS", "0" }, .{ "SQLITE_ENABLE_API_ARMOR", "1" }, .{ "SQLITE_ENABLE_UNLOCK_NOTIFY", "1" }, - .{ "SQLITE_DEFAULT_FILE_PERMISSIONS", "0600" }, + .{ "SQLITE_TEMP_STORE", "3" }, + .{ "SQLITE_THREADSAFE", "1" }, + .{ "SQLITE_UNTESTABLE", "1" }, + .{ "SQLITE_USE_ALLOCA", "1" }, + .{ "SQLITE_OMIT_AUTHORIZATION", "1" }, + .{ "SQLITE_OMIT_AUTOMATIC_INDEX", "1" }, + .{ "SQLITE_OMIT_AUTORESET", "1" }, + .{ "SQLITE_OMIT_AUTOVACUUM", "1" }, + .{ "SQLITE_OMIT_BETWEEN_OPTIMIZATION", "1" }, + .{ "SQLITE_OMIT_CASE_SENSITIVE_LIKE_PRAGMA", "1" }, + .{ "SQLITE_OMIT_COMPLETE", "1" }, .{ "SQLITE_OMIT_DECLTYPE", "1" }, .{ "SQLITE_OMIT_DEPRECATED", "1" }, + .{ "SQLITE_OMIT_DESERIALIZE", "1" }, + .{ "SQLITE_OMIT_GET_TABLE", "1" }, + .{ "SQLITE_OMIT_INCRBLOB", "1" }, + .{ "SQLITE_OMIT_JSON", "1" }, + .{ "SQLITE_OMIT_LIKE_OPTIMIZATION", "1" }, .{ "SQLITE_OMIT_LOAD_EXTENSION", "1" }, .{ "SQLITE_OMIT_PROGRESS_CALLBACK", "1" }, .{ "SQLITE_OMIT_SHARED_CACHE", "1" }, + .{ "SQLITE_OMIT_TCL_VARIABLE", "1" }, + .{ "SQLITE_OMIT_TEMPDB", "1" }, .{ "SQLITE_OMIT_TRACE", "1" }, .{ "SQLITE_OMIT_UTF16", "1" }, + .{ "SQLITE_OMIT_XFER_OPT", "1" }, }; - for (macros) |m| lib.root_module.addCMacro(m[0], m[1]); + for (macros) |m| { + lib.root_module.addCMacro(m[0], m[1]); + } mod.linkLibrary(lib); } diff --git a/src/Config.zig b/src/Config.zig index 52c541ff..a2474755 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -557,8 +557,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Defaults to no caching. \\ \\--storage-engine - \\ The storage engine to use. Choices are: sqlite. - \\ Default to sqlite. + \\ The storage engine to use. Choices are: none, sqlite. + \\ Default to none. \\ \\--storage-sqlite-path \\ Path to SQLite database file for persistent storage. diff --git a/src/storage/Blackhole.zig b/src/storage/Blackhole.zig new file mode 100644 index 00000000..8da0dc8e --- /dev/null +++ b/src/storage/Blackhole.zig @@ -0,0 +1,24 @@ +// Copyright (C) 2023-2026 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 std = @import("std"); +const Allocator = std.mem.Allocator; + +const Blackhole = @This(); + +pub fn deinit(_: *Blackhole, _: Allocator) void {} diff --git a/src/storage/Storage.zig b/src/storage/Storage.zig index 7a04769d..54cefdaf 100644 --- a/src/storage/Storage.zig +++ b/src/storage/Storage.zig @@ -19,6 +19,7 @@ const std = @import("std"); const log = @import("../log.zig"); const Config = @import("../Config.zig"); +const Blackhole = @import("Blackhole.zig"); const Sqlite = @import("sqlite/Sqlite.zig"); const Allocator = std.mem.Allocator; @@ -26,17 +27,19 @@ const Allocator = std.mem.Allocator; const Storage = @This(); pub const EngineType = enum { + none, sqlite, }; const Engine = union(EngineType) { + none: Blackhole, sqlite: Sqlite, }; engine: Engine, pub fn init(allocator: Allocator, config: *const Config) !Storage { - const engine_type = config.storageEngine() orelse .sqlite; + const engine_type = config.storageEngine() orelse .none; const engine = initEngine(allocator, engine_type, config) catch |err| { log.fatal(.storage, "storage setup", .{ .engine = engine_type, .err = err }); return err; @@ -49,6 +52,7 @@ pub fn init(allocator: Allocator, config: *const Config) !Storage { fn initEngine(allocator: Allocator, engine_type: EngineType, config: *const Config) !Engine { switch (engine_type) { + .none => return .{ .none = Blackhole{} }, .sqlite => { const sqlite_path = config.storageSqlitePath(); return .{ .sqlite = try Sqlite.init(allocator, sqlite_path) }; diff --git a/src/storage/sqlite/Sqlite.zig b/src/storage/sqlite/Sqlite.zig index 36b7af99..e020b5a0 100644 --- a/src/storage/sqlite/Sqlite.zig +++ b/src/storage/sqlite/Sqlite.zig @@ -29,8 +29,9 @@ const Sqlite = @This(); pool: Pool, -pub fn init(allocator: Allocator, path: ?[:0]const u8) !Sqlite { - var pool = try Pool.init(allocator, path orelse ":memory:"); +pub fn init(allocator: Allocator, path_: ?[:0]const u8) !Sqlite { + const path = path_ orelse ":memory:"; + var pool = try Pool.init(allocator, path); errdefer pool.deinit(allocator); { @@ -39,7 +40,9 @@ pub fn init(allocator: Allocator, path: ?[:0]const u8) !Sqlite { // pool to the return value (copy A) and then release the original const conn = try pool.acquire(); defer pool.release(conn); - try @import("migrations.zig").run(conn); + + const version = try @import("migrations.zig").run(conn); + log.info(.storage, "storage initialized", .{ .engine = "sqlite", .version = version, .path = path }); } return .{ diff --git a/src/storage/sqlite/migrations.zig b/src/storage/sqlite/migrations.zig index b0eb89f1..e431c443 100644 --- a/src/storage/sqlite/migrations.zig +++ b/src/storage/sqlite/migrations.zig @@ -22,9 +22,9 @@ const Sqlite = @import("Sqlite.zig"); const log = lp.log; -pub fn run(conn: Sqlite.Conn) !void { +pub fn run(conn: Sqlite.Conn) !i64 { const version = try getVersion(conn); - log.info(.storage, "migration version", .{ .engine = "sqlite", .version = version }); + return version; } fn getVersion(conn: Sqlite.Conn) !i64 { From 945e9597bf7dc06d1e6cd6f710f629e4f885105f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 25 Apr 2026 07:58:31 +0800 Subject: [PATCH 2/9] Propagate CLI parsing errors Rather than continue on a failed argument parse, propagate the error so that the program eventually exists. --- src/cli.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 061fa784..a7d8f955 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -405,7 +405,7 @@ pub fn Builder(comptime commands: anytype) type { error.Overflow => log.fatal(.app, "range overflow", .{ .arg = kebab_cased, .value = str }), error.InvalidCharacter => log.fatal(.app, "invalid character", .{ .arg = kebab_cased, .value = str }), } - continue :iter_args; + return error.InvalidArgument; }; if (is_multiple) { @@ -481,6 +481,7 @@ pub fn Builder(comptime commands: anytype) type { // Invalid option choice. log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = trimmed }); + return error.InvalidArgument; } } }, @@ -493,7 +494,7 @@ pub fn Builder(comptime commands: anytype) type { const str = args.next() orelse return error.MissingArgument; const v = std.meta.stringToEnum(E, str) orelse { log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = str }); - continue :iter_args; + return error.InvalidArgument; }; if (is_multiple) { From a2f21ff46316ac58b4611ca3f725f0203eaef364 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 27 Apr 2026 09:27:30 +0800 Subject: [PATCH 3/9] Fix same-url navigate Same-URL navigate should always cause a reload. The code that currently prevents a navigate on fragment change is too loose and treats identical URLs as being a fragment change..but for it to be considered a fragment change, the fragment actually has to change. This improves some WPT compat where tests do: <--- loads "about:blank" --- src/browser/Frame.zig | 5 ++++- src/browser/tests/frames/frames.html | 14 +++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 17c49d0e..50b06646 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -720,7 +720,10 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url }; const session = target._session; - if (!opts.force and URL.eqlDocument(target.url, resolved_url)) { + // Short-circuit only true fragment-only navigations (same path/query, different + // fragment). Identical URLs fall through and trigger a real reload. + const is_fragment_navigation = !std.mem.eql(u8, target.url, resolved_url) and URL.eqlDocument(target.url, resolved_url); + if (!opts.force and is_fragment_navigation) { target.url = try target.arena.dupeZ(u8, resolved_url); target.window._location = try Location.init(target.url, target); if (target.parent == null) { diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index 0d11b1bb..98405cdc 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -151,8 +151,20 @@ } + + + From e30658fae17fb03b3e43dda5304da122e25b2739 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 27 Apr 2026 10:50:48 +0800 Subject: [PATCH 4/9] fix test --- src/browser/tests/window/location.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/tests/window/location.html b/src/browser/tests/window/location.html index b82adfb4..3d6a9348 100644 --- a/src/browser/tests/window/location.html +++ b/src/browser/tests/window/location.html @@ -7,14 +7,14 @@ + + + + diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig index 2a00d81e..f57db2c0 100644 --- a/src/browser/webapi/Location.zig +++ b/src/browser/webapi/Location.zig @@ -20,6 +20,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); const URL = @import("URL.zig"); +const U = @import("../URL.zig"); const Frame = @import("../Frame.zig"); const Location = @This(); @@ -65,6 +66,22 @@ pub fn getHash(self: *const Location) []const u8 { return self._url.getHash(); } +pub fn setPathname(_: *const Location, pathname: []const u8, frame: *Frame) !void { + const new_url = try U.setPathname(frame.url, pathname, frame.call_arena); + return frame.scheduleNavigation(new_url, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .{ .script = frame }); +} + +pub fn setSearch(_: *const Location, search: []const u8, frame: *Frame) !void { + const new_url = try U.setSearch(frame.url, search, frame.call_arena); + return frame.scheduleNavigation(new_url, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .{ .script = frame }); +} + pub fn setHash(_: *const Location, hash: []const u8, frame: *Frame) !void { const normalized_hash = blk: { if (hash.len == 0) { @@ -117,9 +134,9 @@ pub const JsApi = struct { return self.assign(url, frame); } - pub const search = bridge.accessor(Location.getSearch, null, .{}); + pub const search = bridge.accessor(Location.getSearch, Location.setSearch, .{}); pub const hash = bridge.accessor(Location.getHash, Location.setHash, .{}); - pub const pathname = bridge.accessor(Location.getPathname, null, .{}); + pub const pathname = bridge.accessor(Location.getPathname, Location.setPathname, .{}); pub const hostname = bridge.accessor(Location.getHostname, null, .{}); pub const host = bridge.accessor(Location.getHost, null, .{}); pub const port = bridge.accessor(Location.getPort, null, .{}); From 00c42dec4edc048997dfe37b3e9d8e905b0bdee0 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 27 Apr 2026 08:04:47 +0200 Subject: [PATCH 7/9] http: inherit request URL fragment across fragment-less redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 7231 §7.1.2, when a 3xx response carries a Location header without a fragment, the user agent must process the redirect as if the value inherited the fragment of the request URL. URL.resolve follows RFC 3986 §5.3 which drops the base fragment, so handleRedirect now reattaches the original fragment when the resolved target has none. Closes #2263 --- src/browser/HttpClient.zig | 14 +++++++++- src/cdp/domains/page.zig | 54 ++++++++++++++++++++++++++++++++++++++ src/testing.zig | 26 ++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index fc217e3d..0832db1a 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1572,7 +1572,19 @@ pub const Transfer = struct { } const base_url = try conn.getEffectiveUrl(); - break :blk try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); + const resolved = try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); + + // RFC 7231 §7.1.2: if the Location value has no fragment, the redirect + // inherits the fragment from the URI used to generate the request. + // URL.resolve follows RFC 3986 §5.3, which drops the base fragment when + // the relative ref has none, so we re-attach it here. + if (URL.getHash(resolved).len == 0) { + const original_hash = URL.getHash(transfer.url); + if (original_hash.len != 0) { + break :blk try std.mem.joinZ(arena, "", &.{ resolved, original_hash }); + } + } + break :blk resolved; }; try transfer.updateURL(url); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index b0aae79e..2bfb8988 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -1003,6 +1003,60 @@ test "cdp.frame: reload" { } } +test "cdp.frame: navigate inherits original fragment across redirect" { + // RFC 7231 §7.1.2: when a 3xx Location header has no fragment, the redirect + // inherits the fragment of the request URL. + var ctx = try testing.context(); + defer ctx.deinit(); + + var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); + + { + // Location: /redirect-target (no fragment) — must inherit #myfrag. + try ctx.processMessage(.{ + .id = 40, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-no-fragment#myfrag" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target#myfrag", frame.url); + } + + { + // Location: /redirect-target#target_fragment — target's fragment wins. + try ctx.processMessage(.{ + .id = 41, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-with-fragment#requested" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target#target_fragment", frame.url); + } + + { + // No fragment on either side — final URL has no fragment. + try ctx.processMessage(.{ + .id = 42, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-no-fragment" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target", frame.url); + } +} + test "cdp.frame: addScriptToEvaluateOnNewDocument" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/testing.zig b/src/testing.zig index e2500f81..73defeed 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -610,6 +610,32 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/redirect-no-fragment")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/redirect-target" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/redirect-target")) { + return req.respond("landed", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/redirect-with-fragment")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/redirect-target#target_fragment" }, + }, + }); + } + if (std.mem.eql(u8, path, "/xhr/404")) { return req.respond("Not Found", .{ .status = .not_found, From a205bbbf7dbe95c1570a787a2657da9364fdfc50 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 27 Apr 2026 14:56:55 +0800 Subject: [PATCH 8/9] return null on getContext('webgl') and getContext('experimental-webgl') Currently, we return a dummy `WebGLRenderingContext`. But this type implements virtually nothing. From MDN: "If the context identifier is not supported null is returned." Now, normally we like to move things along as much as we can. Returning `WebGLRenderingContext` lets us know the next thing the code needs. But in this case, it seems to be problematic. At least some code is defensive around a null value from getContext('webgl'), but once we return an instance, it expects it to work correctly. This is what causes https://github.com/features/copilot to enter an endless JS loop. --- src/browser/webapi/canvas/WebGLRenderingContext.zig | 9 +++++---- src/browser/webapi/element/html/Canvas.zig | 12 ++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/canvas/WebGLRenderingContext.zig b/src/browser/webapi/canvas/WebGLRenderingContext.zig index ba4b6938..28dcce39 100644 --- a/src/browser/webapi/canvas/WebGLRenderingContext.zig +++ b/src/browser/webapi/canvas/WebGLRenderingContext.zig @@ -204,7 +204,8 @@ pub const JsApi = struct { pub const getSupportedExtensions = bridge.function(WebGLRenderingContext.getSupportedExtensions, .{}); }; -const testing = @import("../../../testing.zig"); -test "WebApi: WebGLRenderingContext" { - try testing.htmlRunner("canvas/webgl_rendering_context.html", .{}); -} +// getContext('web-gl') currently returns null, so this cannot be tested +// const testing = @import("../../../testing.zig"); +// test "WebApi: WebGLRenderingContext" { +// try testing.htmlRunner("canvas/webgl_rendering_context.html", .{}); +// } diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig index e1648f5d..da2bfb43 100644 --- a/src/browser/webapi/element/html/Canvas.zig +++ b/src/browser/webapi/element/html/Canvas.zig @@ -85,9 +85,17 @@ pub fn getContext(self: *Canvas, context_type: []const u8, frame: *Frame) !?Draw break :blk .{ .@"2d" = ctx }; } + // We only stub a tiny slice of the WebGL API (getParameter, + // getExtension, getSupportedExtensions). Real WebGL consumers like + // Three.js immediately call createTexture/createBuffer/etc. and + // throw `TypeError: e.createTexture is not a function`. Pretending + // WebGL works until the first non-stubbed call is the worst of both + // worlds: pages that have an error boundary above the WebGL widget + // catch the throw, reset, re-render, and loop forever. + // Spec-correct signal for "no WebGL" is null, so apps that check + // (Three.js does) can degrade gracefully. if (std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl")) { - const ctx = try frame._factory.create(WebGLRenderingContext{}); - break :blk .{ .webgl = ctx }; + return null; } return null; }; From 9441e4fec8b51fc5bf1b97f269e7c5f673b220e2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 27 Apr 2026 15:57:11 +0800 Subject: [PATCH 9/9] Improve WPT uievents/ tests 1. Add a bunch of initXYZEvent functions, and fix the overload of initTextEvent 2. Add a number of missing (mostly legacy) accessors, e.g. uievent.getWhich() 3. CompositionEvent inherits from UIEvent, not Event 4. Give every accessor get/set a name. E.g the "name" of script.src is "get script" and "set script". --- src/browser/js/Snapshot.zig | 29 ++++++--- .../tests/element/html/script/script.html | 10 +++ src/browser/tests/event/composition.html | 36 +++++++++++ src/browser/tests/event/keyboard.html | 51 +++++++++++++++ src/browser/tests/event/mouse.html | 64 +++++++++++++++++++ src/browser/tests/event/text.html | 35 +++++++--- src/browser/tests/event/ui.html | 30 +++++++++ src/browser/webapi/Document.zig | 5 ++ src/browser/webapi/Event.zig | 2 - src/browser/webapi/event/CompositionEvent.zig | 31 ++++++++- src/browser/webapi/event/KeyboardEvent.zig | 51 +++++++++++++++ src/browser/webapi/event/MouseEvent.zig | 63 ++++++++++++++++++ src/browser/webapi/event/TextEvent.zig | 19 +++--- src/browser/webapi/event/UIEvent.zig | 33 +++++++++- 14 files changed, 425 insertions(+), 34 deletions(-) diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index c625127c..533d479c 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -276,9 +276,11 @@ fn createSnapshotContext( const name = JsApi.Meta.name; const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); var maybe_result: v8.MaybeBool = undefined; - var properties: v8.PropertyAttribute = v8.None; - if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) { - properties |= v8.DontEnum; + // Web IDL: interface objects on the global are non-enumerable + // by default. Opt back in via JsApi.Meta.enumerable = true. + var properties: v8.PropertyAttribute = v8.DontEnum; + if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == true) { + properties = v8.None; } v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result); } @@ -578,6 +580,8 @@ pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8 const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi); const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len)); v8.v8__FunctionTemplate__SetClassName(template, class_name); + // Web IDL: interface object's `prototype` property is non-writable/non-configurable. + v8.v8__FunctionTemplate__ReadOnlyPrototype(template); return template; } @@ -615,13 +619,21 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F .callback = value.getter, .signature = getter_signature, }).?; - const setter_callback = if (value.setter) |setter| - v8.v8__FunctionTemplate__New__Config(isolate, &.{ + // WebIDL: getter function's .name should be "get X" + const getter_name_str = "get " ++ name; + const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len)); + v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8); + + const setter_callback = if (value.setter) |setter| blk: { + const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = setter, .signature = getter_signature, - }).? - else - null; + }).?; + const setter_name_str = "set " ++ name; + const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len)); + v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8); + break :blk cb; + } else null; var attribute: v8.PropertyAttribute = 0; if (value.setter == null) { @@ -655,6 +667,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F .signature = func_signature, }).?; const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); + v8.v8__FunctionTemplate__SetClassName(function_template, js_name); if (value.static) { v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None); } else { diff --git a/src/browser/tests/element/html/script/script.html b/src/browser/tests/element/html/script/script.html index 4f826fe7..d49d025a 100644 --- a/src/browser/tests/element/html/script/script.html +++ b/src/browser/tests/element/html/script/script.html @@ -25,5 +25,15 @@ testing.expectEqual(true, dom_load); testing.expectEqual(true, attribute_load); }); + + // Web IDL requires attribute getters/setters to be named "get X" / "set X" + // and methods to use their identifier. Sentinel for the binding-level naming + // fix in Snapshot.zig — Script.src exercises both accessor paths. + const desc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'); + testing.expectEqual('get src', desc.get.name); + testing.expectEqual('set src', desc.set.name); + + const append = Object.getOwnPropertyDescriptor(Element.prototype, 'append'); + testing.expectEqual('append', append.value.name); } diff --git a/src/browser/tests/event/composition.html b/src/browser/tests/event/composition.html index b5a6a710..e9602335 100644 --- a/src/browser/tests/event/composition.html +++ b/src/browser/tests/event/composition.html @@ -5,10 +5,13 @@ { let event = new CompositionEvent("test", {}); testing.expectEqual(true, event instanceof CompositionEvent); + testing.expectEqual(true, event instanceof UIEvent); testing.expectEqual(true, event instanceof Event); testing.expectEqual("test", event.type); testing.expectEqual("", event.data); + testing.expectEqual(0, event.detail); + testing.expectEqual(window, event.view); } @@ -34,3 +37,36 @@ } + + + + + + diff --git a/src/browser/tests/event/keyboard.html b/src/browser/tests/event/keyboard.html index 79baff7e..223a9e0b 100644 --- a/src/browser/tests/event/keyboard.html +++ b/src/browser/tests/event/keyboard.html @@ -116,3 +116,54 @@ testing.expectEqual(true, called); } + + + + + + + + diff --git a/src/browser/tests/event/mouse.html b/src/browser/tests/event/mouse.html index 83fc4045..5314889b 100644 --- a/src/browser/tests/event/mouse.html +++ b/src/browser/tests/event/mouse.html @@ -52,3 +52,67 @@ document.dispatchEvent(new MouseEvent('mousetest')); testing.expectEqual(false, mouseIsTrusted); + + + + + + + + + + diff --git a/src/browser/tests/event/text.html b/src/browser/tests/event/text.html index f885c293..61894502 100644 --- a/src/browser/tests/event/text.html +++ b/src/browser/tests/event/text.html @@ -1,17 +1,32 @@ + + diff --git a/src/browser/tests/event/ui.html b/src/browser/tests/event/ui.html index 50d880ec..f060622a 100644 --- a/src/browser/tests/event/ui.html +++ b/src/browser/tests/event/ui.html @@ -52,3 +52,33 @@ const evt7 = new UIEvent('custom', { bubbles: true }); testing.expectEqual(0, evt7.detail); + + + + + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index a00d301b..a30d32cd 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -396,6 +396,11 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* return (try TextEvent.init("", null, frame)).asEvent(); } + if (std.mem.eql(u8, normalized, "compositionevent")) { + const CompositionEvent = @import("event/CompositionEvent.zig"); + return (try CompositionEvent.init("", null, frame)).asEvent(); + } + return error.NotSupported; } diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index f08914e8..01a3617a 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -71,7 +71,6 @@ pub const Type = union(enum) { custom_event: *@import("event/CustomEvent.zig"), message_event: *@import("event/MessageEvent.zig"), progress_event: *@import("event/ProgressEvent.zig"), - composition_event: *@import("event/CompositionEvent.zig"), navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), page_transition_event: *@import("event/PageTransitionEvent.zig"), pop_state_event: *@import("event/PopStateEvent.zig"), @@ -163,7 +162,6 @@ pub fn is(self: *Event, comptime T: type) ?*T { .custom_event => |e| return if (T == @import("event/CustomEvent.zig")) e else null, .message_event => |e| return if (T == @import("event/MessageEvent.zig")) e else null, .progress_event => |e| return if (T == @import("event/ProgressEvent.zig")) e else null, - .composition_event => |e| return if (T == @import("event/CompositionEvent.zig")) e else null, .navigation_current_entry_change_event => |e| return if (T == @import("event/NavigationCurrentEntryChangeEvent.zig")) e else null, .page_transition_event => |e| return if (T == @import("event/PageTransitionEvent.zig")) e else null, .pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null, diff --git a/src/browser/webapi/event/CompositionEvent.zig b/src/browser/webapi/event/CompositionEvent.zig index b0492a92..3d25a156 100644 --- a/src/browser/webapi/event/CompositionEvent.zig +++ b/src/browser/webapi/event/CompositionEvent.zig @@ -22,12 +22,14 @@ const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); const Event = @import("../Event.zig"); +const UIEvent = @import("UIEvent.zig"); +const Window = @import("../Window.zig"); const String = lp.String; const CompositionEvent = @This(); -_proto: *Event, +_proto: *UIEvent, _data: []const u8 = "", const CompositionEventOptions = struct { @@ -42,7 +44,7 @@ pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*CompositionEvent const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; - const event = try frame._factory.event( + const event = try frame._factory.uiEvent( arena, type_string, CompositionEvent{ @@ -56,13 +58,35 @@ pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*CompositionEvent } pub fn asEvent(self: *CompositionEvent) *Event { - return self._proto; + return self._proto.asEvent(); } pub fn getData(self: *const CompositionEvent) []const u8 { return self._data; } +pub fn initCompositionEvent( + self: *CompositionEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*Window, + data: ?[]const u8, +) !void { + const ui = self._proto; + const event = ui._proto; + if (event._event_phase != .none) { + return; + } + + const arena = event._arena; + event._type_string = try String.init(arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + self._data = if (data) |d| try arena.dupe(u8, d) else ""; +} + pub const JsApi = struct { pub const bridge = js.Bridge(CompositionEvent); @@ -74,6 +98,7 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(CompositionEvent.init, .{}); pub const data = bridge.accessor(CompositionEvent.getData, null, .{}); + pub const initCompositionEvent = bridge.function(CompositionEvent.initCompositionEvent, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig index b2658d02..979b5c29 100644 --- a/src/browser/webapi/event/KeyboardEvent.zig +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -270,6 +270,49 @@ pub fn getShiftKey(self: *const KeyboardEvent) bool { return self._shift_key; } +// Deprecated: tracked as 0 since we don't synthesise legacy character codes. +pub fn getCharCode(self: *const KeyboardEvent) u32 { + _ = self; + return 0; +} + +pub fn getKeyCode(self: *const KeyboardEvent) u32 { + _ = self; + return 0; +} + +pub fn initKeyboardEvent( + self: *KeyboardEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*@import("../Window.zig"), + key: ?[]const u8, + location: ?u32, + ctrl_key: ?bool, + alt_key: ?bool, + shift_key: ?bool, + meta_key: ?bool, +) !void { + const ui = self._proto; + const event = ui._proto; + if (event._event_phase != .none) { + return; + } + + const arena = event._arena; + event._type_string = try String.init(arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + self._key = try Key.fromString(arena, key orelse ""); + self._location = std.meta.intToEnum(Location, location orelse 0) catch return error.TypeError; + self._ctrl_key = ctrl_key orelse false; + self._alt_key = alt_key orelse false; + self._shift_key = shift_key orelse false; + self._meta_key = meta_key orelse false; +} + pub fn getModifierState(self: *const KeyboardEvent, str: []const u8) !bool { const key = try Key.fromString(self._proto._proto._arena, str); @@ -309,7 +352,15 @@ pub const JsApi = struct { pub const metaKey = bridge.accessor(KeyboardEvent.getMetaKey, null, .{}); pub const repeat = bridge.accessor(KeyboardEvent.getRepeat, null, .{}); pub const shiftKey = bridge.accessor(KeyboardEvent.getShiftKey, null, .{}); + pub const charCode = bridge.accessor(KeyboardEvent.getCharCode, null, .{}); + pub const keyCode = bridge.accessor(KeyboardEvent.getKeyCode, null, .{}); pub const getModifierState = bridge.function(KeyboardEvent.getModifierState, .{}); + pub const initKeyboardEvent = bridge.function(KeyboardEvent.initKeyboardEvent, .{}); + + pub const DOM_KEY_LOCATION_STANDARD = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_STANDARD), .{ .template = true }); + pub const DOM_KEY_LOCATION_LEFT = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_LEFT), .{ .template = true }); + pub const DOM_KEY_LOCATION_RIGHT = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_RIGHT), .{ .template = true }); + pub const DOM_KEY_LOCATION_NUMPAD = bridge.property(@intFromEnum(Location.DOM_KEY_LOCATION_NUMPAD), .{ .template = true }); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index eaa0315e..240ce6c8 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -193,6 +193,65 @@ pub fn getShiftKey(self: *const MouseEvent) bool { return self._shift_key; } +// Deprecated: tracks the same value as offsetX/clientX in the absence of layout. +pub fn getLayerX(self: *const MouseEvent) f64 { + return self._client_x; +} + +pub fn getLayerY(self: *const MouseEvent) f64 { + return self._client_y; +} + +pub fn getModifierState(self: *const MouseEvent, key: []const u8) bool { + if (std.mem.eql(u8, key, "Alt") or std.mem.eql(u8, key, "AltGraph")) return self._alt_key; + if (std.mem.eql(u8, key, "Control")) return self._ctrl_key; + if (std.mem.eql(u8, key, "Shift")) return self._shift_key; + if (std.mem.eql(u8, key, "Meta")) return self._meta_key; + if (std.mem.eql(u8, key, "Accel")) return self._ctrl_key or self._meta_key; + return false; +} + +pub fn initMouseEvent( + self: *MouseEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*@import("../Window.zig"), + detail: ?i32, + screen_x: ?i32, + screen_y: ?i32, + client_x: ?i32, + client_y: ?i32, + ctrl_key: ?bool, + alt_key: ?bool, + shift_key: ?bool, + meta_key: ?bool, + button: ?i16, + related_target: ?*EventTarget, +) !void { + const ui = self._proto; + const event = ui._proto; + if (event._event_phase != .none) { + return; + } + + event._type_string = try String.init(event._arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + ui._detail = if (detail) |d| @intCast(@max(d, 0)) else 0; + self._screen_x = @floatFromInt(screen_x orelse 0); + self._screen_y = @floatFromInt(screen_y orelse 0); + self._client_x = @floatFromInt(client_x orelse 0); + self._client_y = @floatFromInt(client_y orelse 0); + self._ctrl_key = ctrl_key orelse false; + self._alt_key = alt_key orelse false; + self._shift_key = shift_key orelse false; + self._meta_key = meta_key orelse false; + self._button = std.meta.intToEnum(MouseButton, button orelse 0) catch return error.TypeError; + self._related_target = related_target; +} + pub const JsApi = struct { pub const bridge = js.Bridge(MouseEvent); @@ -218,8 +277,12 @@ pub const JsApi = struct { pub const screenX = bridge.accessor(getScreenX, null, .{}); pub const screenY = bridge.accessor(getScreenY, null, .{}); pub const shiftKey = bridge.accessor(getShiftKey, null, .{}); + pub const layerX = bridge.accessor(getLayerX, null, .{}); + pub const layerY = bridge.accessor(getLayerY, null, .{}); pub const x = bridge.accessor(getClientX, null, .{}); pub const y = bridge.accessor(getClientY, null, .{}); + pub const getModifierState = bridge.function(MouseEvent.getModifierState, .{}); + pub const initMouseEvent = bridge.function(MouseEvent.initMouseEvent, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/event/TextEvent.zig b/src/browser/webapi/event/TextEvent.zig index 74dc18fc..7662320f 100644 --- a/src/browser/webapi/event/TextEvent.zig +++ b/src/browser/webapi/event/TextEvent.zig @@ -72,24 +72,23 @@ pub fn getData(self: *const TextEvent) []const u8 { pub fn initTextEvent( self: *TextEvent, typ: []const u8, - bubbles: bool, - cancelable: bool, + bubbles: ?bool, + cancelable: ?bool, view: ?*@import("../Window.zig"), - data: []const u8, + data: ?[]const u8, ) !void { - _ = view; // view parameter is ignored in modern implementations - - const event = self._proto._proto; + const ui = self._proto; + const event = ui._proto; if (event._event_phase != .none) { - // Only allow initialization if event hasn't been dispatched return; } const arena = event._arena; event._type_string = try String.init(arena, typ, .{}); - event._bubbles = bubbles; - event._cancelable = cancelable; - self._data = try arena.dupe(u8, data); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + ui._view = view; + self._data = if (data) |d| try arena.dupe(u8, d) else ""; } pub const JsApi = struct { diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig index 4122edde..13671c5e 100644 --- a/src/browser/webapi/event/UIEvent.zig +++ b/src/browser/webapi/event/UIEvent.zig @@ -40,6 +40,7 @@ pub const Type = union(enum) { focus_event: *@import("FocusEvent.zig"), text_event: *@import("TextEvent.zig"), input_event: *@import("InputEvent.zig"), + composition_event: *@import("CompositionEvent.zig"), }; pub const UIEventOptions = struct { @@ -88,6 +89,7 @@ pub fn is(self: *UIEvent, comptime T: type) ?*T { .focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null, .text_event => |e| return if (T == @import("TextEvent.zig")) e else null, .input_event => |e| return if (T == @import("InputEvent.zig")) e else null, + .composition_event => |e| return if (T == @import("CompositionEvent.zig")) e else null, } return null; } @@ -111,7 +113,34 @@ pub fn getView(self: *UIEvent, frame: *Frame) *Window { return self._view orelse frame.window; } -// deprecated `initUIEvent()` not implemented +// Legacy: see https://w3c.github.io/uievents/#dom-uievent-which +pub fn getWhich(self: *const UIEvent) u32 { + return switch (self._type) { + .mouse_event => |me| @as(u32, @intCast(me.getButton())) + 1, + .keyboard_event => 0, + else => 0, + }; +} + +pub fn initUIEvent( + self: *UIEvent, + typ: []const u8, + bubbles: ?bool, + cancelable: ?bool, + view: ?*Window, + detail: ?i32, +) !void { + const event = self._proto; + if (event._event_phase != .none) { + return; + } + + event._type_string = try String.init(event._arena, typ, .{}); + event._bubbles = bubbles orelse false; + event._cancelable = cancelable orelse false; + self._view = view; + self._detail = if (detail) |d| @intCast(@max(d, 0)) else 0; +} pub const JsApi = struct { pub const bridge = js.Bridge(UIEvent); @@ -125,6 +154,8 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(UIEvent.init, .{}); pub const detail = bridge.accessor(UIEvent.getDetail, null, .{}); pub const view = bridge.accessor(UIEvent.getView, null, .{}); + pub const which = bridge.accessor(UIEvent.getWhich, null, .{}); + pub const initUIEvent = bridge.function(UIEvent.initUIEvent, .{}); }; const testing = @import("../../../testing.zig");