Merge pull request #2790 from lightpanda-io/hash-change-event

webapi: add HashChangeEvent and Window hashchange event
This commit is contained in:
Pierre Tachoire
2026-06-24 06:28:41 +00:00
committed by GitHub
8 changed files with 289 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=hashChangeConstructor>
{
const event = new HashChangeEvent('hashchange');
testing.expectEqual('hashchange', event.type);
testing.expectEqual('', event.oldURL);
testing.expectEqual('', event.newURL);
testing.expectEqual(false, event.bubbles);
testing.expectEqual(false, event.cancelable);
testing.expectTrue(event instanceof Event);
testing.expectTrue(event instanceof HashChangeEvent);
}
</script>
<script id=hashChangeWithOptions>
{
const event = new HashChangeEvent('hashchange', {
oldURL: 'http://example.com/#a',
newURL: 'http://example.com/#b',
bubbles: true,
cancelable: true,
});
testing.expectEqual('http://example.com/#a', event.oldURL);
testing.expectEqual('http://example.com/#b', event.newURL);
testing.expectEqual(true, event.bubbles);
testing.expectEqual(true, event.cancelable);
}
</script>
<script id=hashChangeDispatch>
{
let received = null;
window.addEventListener('hashchange', (e) => { received = e; }, { once: true });
const event = new HashChangeEvent('hashchange', {
oldURL: 'http://example.com/#old',
newURL: 'http://example.com/#new',
});
window.dispatchEvent(event);
testing.expectEqual('http://example.com/#old', received.oldURL);
testing.expectEqual('http://example.com/#new', received.newURL);
}
</script>
<script id=hashChangeFiresOnFragmentNavigation type=module>
{
// hashchange is fired by queuing a task (not synchronously): the handler
// below is registered AFTER the first `location.hash` change yet still
// observes it, and each fragment change fires its own event.
const state = await testing.async();
const root = location.href.split('#')[0];
const oldURLs = [];
const newURLs = [];
location.hash = '#foo';
window.onhashchange = (e) => {
if (newURLs.length === 0) {
testing.expectEqual('hashchange', e.type);
testing.expectTrue(e.isTrusted);
testing.expectEqual(window, e.target);
testing.expectTrue(e instanceof HashChangeEvent);
testing.expectEqual(false, e.bubbles);
testing.expectEqual(false, e.cancelable);
}
oldURLs.push(e.oldURL);
newURLs.push(e.newURL);
if (newURLs.length === 2) state.resolve();
};
location.hash = '#bar';
await state.done(() => {
testing.expectEqual([root, root + '#foo'], oldURLs);
testing.expectEqual([root + '#foo', root + '#bar'], newURLs);
window.onhashchange = null;
});
}
</script>
<script id=hashChangeFiresOnHistoryTraversal type=module>
{
// Traversing history (history.back) between entries that share a document
// but differ only by fragment must also fire hashchange. The decision is
// owned by Navigation.navigateInner (single resolved-URL source of truth),
// not re-derived in History.goInner.
const state = await testing.async();
const events = [];
window.onhashchange = (e) => {
events.push([e.oldURL, e.newURL]);
if (events.length === 2) state.resolve();
};
// Relative to the document's current URL (a prior block leaves a fragment
// here): pushState creates a fragment-only entry without firing hashchange,
// then back/forward traverse between the two entries.
const before = location.href;
history.pushState(null, '', '#section'); // entry .../#section; no hashchange
const after = location.href;
history.back(); // traverse: after -> before
history.forward(); // traverse: before -> after
await state.done(() => {
testing.expectEqual([
[after, before],
[before, after],
], events);
window.onhashchange = null;
});
}
</script>

View File

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

View File

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

View File

@@ -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, .{});

View File

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

View File

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