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(