Implement XMLHttpRequest.upload

Return an XMLHttpRequestUpload (inheriting XMLHttpRequestEventTarget) from
the lazily-cached `upload` attribute. Fixes htmx login flows that called
`xhr.upload.addEventListener(...)`.
This commit is contained in:
Pierre Tachoire
2026-06-04 11:45:30 +02:00
parent 5b6064309e
commit 691a32ff6e
5 changed files with 97 additions and 24 deletions

View File

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

View File

@@ -329,3 +329,22 @@
});
}
</script>
<script id=xhr_upload type=module>
{
// upload is an XMLHttpRequestUpload (an XMLHttpRequestEventTarget) and
// exposes addEventListener; htmx relies on this not throwing.
const req = new XMLHttpRequest();
testing.expectEqual('XMLHttpRequestUpload', req.upload.constructor.name);
testing.expectEqual(true, req.upload instanceof XMLHttpRequestEventTarget);
testing.expectEqual(true, req.upload instanceof EventTarget);
testing.expectEqual('function', typeof req.upload.addEventListener);
// The same instance is returned on every access so listeners stick.
testing.expectEqual(req.upload, req.upload);
let registered = false;
req.upload.addEventListener('progress', () => { registered = true; });
testing.expectEqual(false, registered);
}
</script>

View File

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

View File

@@ -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) {

View File

@@ -0,0 +1,46 @@
// 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 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;
};
};