From 84098c01890fda251d9c8b73896cb9075cfbd963 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 22 Jun 2026 09:48:02 +0200 Subject: [PATCH 1/5] webapi: add HashChangeEvent and Window hashchange event Implement the HashChangeEvent interface and fire a `hashchange` event on the window whenever the URL fragment changes without a document reload, mirroring the existing PopStateEvent/onpopstate wiring. Per spec, hashchange is fired by queuing a task rather than synchronously, so a listener registered after the fragment change still observes it and each fragment change fires its own event. The task is queued via a factory-allocated callback (the same async-dispatch pattern as MessagePort and BroadcastChannel), capturing the old/new URLs since the frame's URL keeps changing synchronously before the task runs. --- src/browser/Frame.zig | 52 ++++++++++ src/browser/js/bridge.zig | 1 + src/browser/tests/event/hashchange.html | 83 +++++++++++++++ src/browser/webapi/Event.zig | 2 + src/browser/webapi/History.zig | 8 ++ src/browser/webapi/Window.zig | 10 ++ src/browser/webapi/event/HashChangeEvent.zig | 103 +++++++++++++++++++ 7 files changed, 259 insertions(+) create mode 100644 src/browser/tests/event/hashchange.html create mode 100644 src/browser/webapi/event/HashChangeEvent.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ae4761b9b..51f33776c 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -58,6 +58,7 @@ const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); +const HashChangeEvent = @import("webapi/event/HashChangeEvent.zig"); const popover = @import("webapi/element/popover.zig"); const slotting = @import("webapi/element/slotting.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; @@ -832,6 +833,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url // 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) { + const old_url = target.url; target.url = try target.arena.dupeZ(u8, resolved_url); const location = try Location.init(target.url, target); @@ -842,6 +844,9 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url if (target.parent == null) { try session.navigation.updateEntries(target.url, opts.kind, target, true); } + + try target.queueHashChange(old_url, target.url); + // don't defer this, the caller is responsible for freeing it on error session.releaseArena(arena); return; @@ -1931,6 +1936,53 @@ pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent } } +// Per spec, `hashchange` is fired by queuing a task rather than synchronously, +// so a listener registered after the fragment change still observes it. The +// captured URLs are duped because the frame's URL keeps changing synchronously +// before the task runs. Mirrors the async-dispatch pattern of MessagePort etc. +const HashChangeCallback = struct { + frame: *Frame, + old_url: []const u8, + new_url: []const u8, + + // Called by the scheduler if the task is dropped before it runs (e.g. the + // page is torn down). + fn cancelled(ctx: *anyopaque) void { + const self: *HashChangeCallback = @ptrCast(@alignCast(ctx)); + self.frame._factory.destroy(self); + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *HashChangeCallback = @ptrCast(@alignCast(ctx)); + defer self.frame._factory.destroy(self); + + const frame = self.frame; + const target = frame.window.asEventTarget(); + if (!frame._event_manager.hasDirectListeners(target, "hashchange", frame.window._on_hashchange)) { + return null; + } + + const event = (try HashChangeEvent.initTrusted(comptime .wrap("hashchange"), .{ + .oldURL = self.old_url, + .newURL = self.new_url, + }, frame)).asEvent(); + try frame._event_manager.dispatchDirect(target, event, frame.window._on_hashchange, .{ .context = "Hash Change" }); + return null; + } +}; + +pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) !void { + const callback = try self._factory.create(HashChangeCallback{ + .frame = self, + .old_url = try self.arena.dupe(u8, old_url), + .new_url = try self.arena.dupe(u8, new_url), + }); + try self.js.scheduler.add(callback, HashChangeCallback.run, 0, .{ + .name = "frame.hashChange", + .finalizer = HashChangeCallback.cancelled, + }); +} + // Hard cap on a single external stylesheet body. CSS rule storage is per- // arena so a hostile sheet could otherwise inflate page memory; 2 MiB is // well above anything seen on real sites (Tailwind's `preflight + utilities` diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index eeb6110a7..0b31451f2 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -920,6 +920,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/event/NavigationCurrentEntryChangeEvent.zig"), @import("../webapi/event/PageTransitionEvent.zig"), @import("../webapi/event/PopStateEvent.zig"), + @import("../webapi/event/HashChangeEvent.zig"), @import("../webapi/event/UIEvent.zig"), @import("../webapi/event/MouseEvent.zig"), @import("../webapi/event/PointerEvent.zig"), diff --git a/src/browser/tests/event/hashchange.html b/src/browser/tests/event/hashchange.html new file mode 100644 index 000000000..595851bf9 --- /dev/null +++ b/src/browser/tests/event/hashchange.html @@ -0,0 +1,83 @@ + + + + + + + + + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 5816d336b..8ac412b3d 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -75,6 +75,7 @@ pub const Type = union(enum) { navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"), page_transition_event: *@import("event/PageTransitionEvent.zig"), pop_state_event: *@import("event/PopStateEvent.zig"), + hash_change_event: *@import("event/HashChangeEvent.zig"), ui_event: *@import("event/UIEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), submit_event: *@import("event/SubmitEvent.zig"), @@ -168,6 +169,7 @@ pub fn is(self: *Event, comptime T: type) ?*T { .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, + .hash_change_event => |e| return if (T == @import("event/HashChangeEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, .form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null, diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index 28837be75..0136ee948 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -19,6 +19,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); +const URL = @import("../URL.zig"); const Frame = @import("../Frame.zig"); const PopStateEvent = @import("event/PopStateEvent.zig"); @@ -98,6 +99,13 @@ fn goInner(delta: i32, frame: *Frame) !void { const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, frame)).asEvent(); try frame._event_manager.dispatchDirect(target, event, frame.window._on_popstate, .{ .context = "Pop State" }); } + + // Queue hashchange when traversing between entries that share a + // document but differ only by fragment. + const is_fragment_change = !std.mem.eql(u8, frame.url, url) and URL.eqlDocument(frame.url, url); + if (is_fragment_change) { + try frame.queueHashChange(frame.url, url); + } } } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 522417784..7a488fac7 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -78,6 +78,7 @@ _cookie_store: ?*CookieStore = null, _on_load: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, +_on_hashchange: ?js.Function.Global = null, _on_error: ?js.Function.Global = null, _on_message: ?js.Function.Global = null, _on_rejection_handled: ?js.Function.Global = null, @@ -325,6 +326,14 @@ pub fn setOnPopState(self: *Window, setter: ?FunctionSetter) void { self._on_popstate = getFunctionFromSetter(setter); } +pub fn getOnHashChange(self: *const Window) ?js.Function.Global { + return self._on_hashchange; +} + +pub fn setOnHashChange(self: *Window, setter: ?FunctionSetter) void { + self._on_hashchange = getFunctionFromSetter(setter); +} + pub fn getOnError(self: *const Window) ?js.Function.Global { return self._on_error; } @@ -1009,6 +1018,7 @@ pub const JsApi = struct { pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{}); pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{}); pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{}); + pub const onhashchange = bridge.accessor(Window.getOnHashChange, Window.setOnHashChange, .{}); pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{}); pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{}); pub const onrejectionhandled = bridge.accessor(Window.getOnRejectionHandled, Window.setOnRejectionHandled, .{}); diff --git a/src/browser/webapi/event/HashChangeEvent.zig b/src/browser/webapi/event/HashChangeEvent.zig new file mode 100644 index 000000000..975107f27 --- /dev/null +++ b/src/browser/webapi/event/HashChangeEvent.zig @@ -0,0 +1,103 @@ +// 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 lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://developer.mozilla.org/en-US/docs/Web/API/HashChangeEvent +const HashChangeEvent = @This(); + +_proto: *Event, +_old_url: []const u8, +_new_url: []const u8, + +const HashChangeEventOptions = struct { + oldURL: []const u8 = "", + newURL: []const u8 = "", +}; + +const Options = Event.inheritOptions(HashChangeEvent, HashChangeEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*HashChangeEvent { + const arena = try frame.getArena(.tiny, "HashChangeEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + return initWithTrusted(arena, type_string, _opts, false, frame); +} + +pub fn initTrusted(typ: String, _opts: ?Options, frame: *Frame) !*HashChangeEvent { + const arena = try frame.getArena(.tiny, "HashChangeEvent.trusted"); + errdefer frame.releaseArena(arena); + return initWithTrusted(arena, typ, _opts, true, frame); +} + +fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, frame: *Frame) !*HashChangeEvent { + const opts = _opts orelse Options{}; + + const event = try frame._factory.event( + arena, + typ, + HashChangeEvent{ + ._proto = undefined, + ._old_url = opts.oldURL, + ._new_url = opts.newURL, + }, + ); + + Event.populatePrototypes(event, opts, trusted); + return event; +} + +pub fn asEvent(self: *HashChangeEvent) *Event { + return self._proto; +} + +pub fn getOldURL(self: *const HashChangeEvent) []const u8 { + return self._old_url; +} + +pub fn getNewURL(self: *const HashChangeEvent) []const u8 { + return self._new_url; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(HashChangeEvent); + + pub const Meta = struct { + pub const name = "HashChangeEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(HashChangeEvent.init, .{}); + pub const oldURL = bridge.accessor(HashChangeEvent.getOldURL, null, .{}); + pub const newURL = bridge.accessor(HashChangeEvent.getNewURL, null, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: HashChangeEvent" { + try testing.htmlRunner("event/hashchange.html", .{}); +} From b78940d7f66459a12aa0346a7a84f7b30d35c046 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 22 Jun 2026 19:00:23 +0200 Subject: [PATCH 2/5] remove useless url duplications --- src/browser/Frame.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 51f33776c..3b8ad4e31 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1938,8 +1938,10 @@ pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent // Per spec, `hashchange` is fired by queuing a task rather than synchronously, // so a listener registered after the fragment change still observes it. The -// captured URLs are duped because the frame's URL keeps changing synchronously -// before the task runs. Mirrors the async-dispatch pattern of MessagePort etc. +// old/new URLs are captured by slice: both already point to arena memory that +// outlives the task, and arena allocations are never freed individually, so a +// synchronous reassignment of the frame's URL before the task runs leaves the +// captured blocks intact. Mirrors the async-dispatch pattern of MessagePort etc. const HashChangeCallback = struct { frame: *Frame, old_url: []const u8, @@ -1974,8 +1976,8 @@ const HashChangeCallback = struct { pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) !void { const callback = try self._factory.create(HashChangeCallback{ .frame = self, - .old_url = try self.arena.dupe(u8, old_url), - .new_url = try self.arena.dupe(u8, new_url), + .old_url = old_url, + .new_url = new_url, }); try self.js.scheduler.add(callback, HashChangeCallback.run, 0, .{ .name = "frame.hashChange", From c450247b312773a01db470fac8e53a25f47edacd Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 23 Jun 2026 17:31:12 +0200 Subject: [PATCH 3/5] fix UAF: copy urls into event's arena --- src/browser/webapi/event/HashChangeEvent.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/event/HashChangeEvent.zig b/src/browser/webapi/event/HashChangeEvent.zig index 975107f27..0c3d5b5d5 100644 --- a/src/browser/webapi/event/HashChangeEvent.zig +++ b/src/browser/webapi/event/HashChangeEvent.zig @@ -62,8 +62,8 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool typ, HashChangeEvent{ ._proto = undefined, - ._old_url = opts.oldURL, - ._new_url = opts.newURL, + ._old_url = try arena.dupe(u8, opts.oldURL), + ._new_url = try arena.dupe(u8, opts.newURL), }, ); From b989ea6b477f07f3e8432ea53996870de32cfaad Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 23 Jun 2026 17:42:27 +0200 Subject: [PATCH 4/5] remove useless comment --- src/browser/Frame.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 3b8ad4e31..ceac93c87 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1936,12 +1936,6 @@ pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent } } -// Per spec, `hashchange` is fired by queuing a task rather than synchronously, -// so a listener registered after the fragment change still observes it. The -// old/new URLs are captured by slice: both already point to arena memory that -// outlives the task, and arena allocations are never freed individually, so a -// synchronous reassignment of the frame's URL before the task runs leaves the -// captured blocks intact. Mirrors the async-dispatch pattern of MessagePort etc. const HashChangeCallback = struct { frame: *Frame, old_url: []const u8, From 846d6fd81668f219753212e54b755da06d57af64 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 23 Jun 2026 18:03:04 +0200 Subject: [PATCH 5/5] fix: single source of truth for hashchange fragment decision History.goInner re-derived the fragment-vs-document decision from the raw entry._url, while navigateInner computes it from the resolved URL. These diverge when an entry stores a non-canonical URL, queuing a spurious hashchange (or missing one). Let navigateInner own the decision and queue the event for all same-document navigations; drop goInner's duplicate. --- src/browser/tests/event/hashchange.html | 33 ++++++++++++++++++++ src/browser/webapi/History.zig | 9 +----- src/browser/webapi/navigation/Navigation.zig | 8 +++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/browser/tests/event/hashchange.html b/src/browser/tests/event/hashchange.html index 595851bf9..acf66ccb6 100644 --- a/src/browser/tests/event/hashchange.html +++ b/src/browser/tests/event/hashchange.html @@ -81,3 +81,36 @@ }); } + + diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index 0136ee948..b09df743e 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -19,7 +19,6 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const URL = @import("../URL.zig"); const Frame = @import("../Frame.zig"); const PopStateEvent = @import("event/PopStateEvent.zig"); @@ -99,13 +98,7 @@ fn goInner(delta: i32, frame: *Frame) !void { const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, frame)).asEvent(); try frame._event_manager.dispatchDirect(target, event, frame.window._on_popstate, .{ .context = "Pop State" }); } - - // Queue hashchange when traversing between entries that share a - // document but differ only by fragment. - const is_fragment_change = !std.mem.eql(u8, frame.url, url) and URL.eqlDocument(frame.url, url); - if (is_fragment_change) { - try frame.queueHashChange(frame.url, url); - } + // hashchange is queued by navigateInner. } } diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig index 41ce62f0b..532abd00c 100644 --- a/src/browser/webapi/navigation/Navigation.zig +++ b/src/browser/webapi/navigation/Navigation.zig @@ -298,6 +298,10 @@ pub fn navigateInner( new_url = try arena.dupeZ(u8, new_url); } + // Captured before the switch overwrites frame.url in the same_document + // branches; used to queue the hashchange once below. + const old_url = frame.url; + const previous = self.getCurrentEntry(); switch (kind) { @@ -345,6 +349,10 @@ pub fn navigateInner( }, } + if (is_same_document and !std.mem.eql(u8, old_url, new_url)) { + try frame.queueHashChange(old_url, new_url); + } + if (self._on_currententrychange) |cec| { // If we haven't navigated off, let us fire off an a currententrychange. const event = (try NavigationCurrentEntryChangeEvent.initTrusted(