From 691a32ff6e64c51b7c19c7d6ac4c5b331d33b741 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 4 Jun 2026 11:45:30 +0200 Subject: [PATCH] Implement XMLHttpRequest.upload Return an XMLHttpRequestUpload (inheriting XMLHttpRequestEventTarget) from the lazily-cached `upload` attribute. Fixes htmx login flows that called `xhr.upload.addEventListener(...)`. --- src/browser/js/bridge.zig | 2 + src/browser/tests/net/xhr.html | 19 ++++++++ src/browser/webapi/net/XMLHttpRequest.zig | 41 ++++++++--------- .../webapi/net/XMLHttpRequestEventTarget.zig | 13 +++++- .../webapi/net/XMLHttpRequestUpload.zig | 46 +++++++++++++++++++ 5 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 src/browser/webapi/net/XMLHttpRequestUpload.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index ef215554..eb236811 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -949,6 +949,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/net/URLSearchParams.zig"), @import("../webapi/net/XMLHttpRequest.zig"), @import("../webapi/net/XMLHttpRequestEventTarget.zig"), + @import("../webapi/net/XMLHttpRequestUpload.zig"), @import("../webapi/net/WebSocket.zig"), @import("../webapi/event/CloseEvent.zig"), @import("../webapi/streams/ReadableStream.zig"), @@ -1036,6 +1037,7 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"), @import("../webapi/net/XMLHttpRequest.zig"), @import("../webapi/net/XMLHttpRequestEventTarget.zig"), + @import("../webapi/net/XMLHttpRequestUpload.zig"), @import("../webapi/net/WebSocket.zig"), @import("../webapi/FileReader.zig"), @import("../webapi/ImageData.zig"), diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index b76dde85..d35c8f0b 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -329,3 +329,22 @@ }); } + + diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index a80416ea..bed5970b 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -34,6 +34,7 @@ const EventTarget = @import("../EventTarget.zig"); const Headers = @import("Headers.zig"); const BodyInit = @import("body_init.zig").BodyInit; const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig"); +const XMLHttpRequestUpload = @import("XMLHttpRequestUpload.zig"); const log = lp.log; const Execution = js.Execution; @@ -44,6 +45,7 @@ const XMLHttpRequest = @This(); _rc: lp.RC(u8) = .{}, _exec: *const Execution, _proto: *XMLHttpRequestEventTarget, +_upload: ?*XMLHttpRequestUpload = null, _arena: Allocator, _http_response: ?HttpClient.Response = null, _active_request: bool = false, @@ -112,29 +114,9 @@ pub fn deinit(self: *XMLHttpRequest, page: *Page) void { func.release(); } - { - const proto = self._proto; - if (proto._on_abort) |func| { - func.release(); - } - if (proto._on_error) |func| { - func.release(); - } - if (proto._on_load) |func| { - func.release(); - } - if (proto._on_load_end) |func| { - func.release(); - } - if (proto._on_load_start) |func| { - func.release(); - } - if (proto._on_progress) |func| { - func.release(); - } - if (proto._on_timeout) |func| { - func.release(); - } + self._proto.releaseListeners(); + if (self._upload) |upload| { + upload._proto.releaseListeners(); } page.releaseArena(self._arena); @@ -289,6 +271,18 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v }; } +// https://xhr.spec.whatwg.org/#the-upload-attribute +// The XMLHttpRequestUpload object is created lazily and cached: scripts expect +// the same instance on every access so their event listeners stick. +pub fn getUpload(self: *XMLHttpRequest) !*XMLHttpRequestUpload { + if (self._upload) |upload| { + return upload; + } + const upload = try self._exec._factory.xhrEventTarget(self._arena, XMLHttpRequestUpload{ ._proto = undefined }); + self._upload = upload; + return upload; +} + pub fn getReadyState(self: *const XMLHttpRequest) u32 { return @intFromEnum(self._ready_state); } @@ -611,6 +605,7 @@ pub const JsApi = struct { pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.done), .{ .template = true }); pub const onreadystatechange = bridge.accessor(XMLHttpRequest.getOnReadyStateChange, XMLHttpRequest.setOnReadyStateChange, .{}); + pub const upload = bridge.accessor(XMLHttpRequest.getUpload, null, .{}); pub const timeout = bridge.accessor(XMLHttpRequest.getTimeout, XMLHttpRequest.setTimeout, .{}); pub const withCredentials = bridge.accessor(XMLHttpRequest.getWithCredentials, XMLHttpRequest.setWithCredentials, .{ .dom_exception = true }); pub const open = bridge.function(XMLHttpRequest.open, .{}); diff --git a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig index af5fd38f..a5b64dc0 100644 --- a/src/browser/webapi/net/XMLHttpRequestEventTarget.zig +++ b/src/browser/webapi/net/XMLHttpRequestEventTarget.zig @@ -37,13 +37,24 @@ _on_timeout: ?js.Function.Temp = null, pub const Type = union(enum) { request: *@import("XMLHttpRequest.zig"), - // TODO: xml_http_request_upload + upload: *@import("XMLHttpRequestUpload.zig"), }; pub fn asEventTarget(self: *XMLHttpRequestEventTarget) *EventTarget { return self._proto; } +pub fn releaseListeners(self: *XMLHttpRequestEventTarget) void { + inline for (.{ + "_on_abort", "_on_error", "_on_load", "_on_load_end", + "_on_load_start", "_on_progress", "_on_timeout", + }) |field| { + if (@field(self, field)) |func| { + func.release(); + } + } +} + pub fn dispatch(self: *XMLHttpRequestEventTarget, comptime event_type: DispatchType, progress_: ?Progress, exec: *const Execution) !void { const field, const typ = comptime blk: { break :blk switch (event_type) { diff --git a/src/browser/webapi/net/XMLHttpRequestUpload.zig b/src/browser/webapi/net/XMLHttpRequestUpload.zig new file mode 100644 index 00000000..68b04597 --- /dev/null +++ b/src/browser/webapi/net/XMLHttpRequestUpload.zig @@ -0,0 +1,46 @@ +// 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 js = @import("../../js/js.zig"); + +const EventTarget = @import("../EventTarget.zig"); +const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig"); + +// https://xhr.spec.whatwg.org/#xmlhttprequestupload +// +// Returned by XMLHttpRequest.upload. It only inherits from +// XMLHttpRequestEventTarget; it has no members of its own. We don't yet emit +// upload progress events, but the object still needs to exist so scripts (e.g. +// htmx) can call addEventListener on it without throwing. +const XMLHttpRequestUpload = @This(); + +_proto: *XMLHttpRequestEventTarget, + +pub fn asEventTarget(self: *XMLHttpRequestUpload) *EventTarget { + return self._proto.asEventTarget(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(XMLHttpRequestUpload); + + pub const Meta = struct { + pub const name = "XMLHttpRequestUpload"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; +};