diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index cbf031c3d..f479c377f 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,49 @@ pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent
}
}
+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 = old_url,
+ .new_url = 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..acf66ccb6
--- /dev/null
+++ b/src/browser/tests/event/hashchange.html
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
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..b09df743e 100644
--- a/src/browser/webapi/History.zig
+++ b/src/browser/webapi/History.zig
@@ -98,6 +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" });
}
+ // hashchange is queued by navigateInner.
}
}
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..0c3d5b5d5
--- /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 = try arena.dupe(u8, opts.oldURL),
+ ._new_url = try arena.dupe(u8, 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", .{});
+}
diff --git a/src/browser/webapi/navigation/Navigation.zig b/src/browser/webapi/navigation/Navigation.zig
index 0ce614380..a48638629 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(