From e9bf4b732e9f4e9b500d21bc77b4e5fe43f4fcb4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 5 Jun 2026 14:27:23 +0200 Subject: [PATCH 1/7] form: encode file inputs as multipart/form-data on submit collectForm now emits File entries for (one per selected file, or a synthetic empty File with application/octet-stream when none is selected, per WHATWG). multipartEncodeEntry writes the filename, Content-Type, and file bytes per RFC 7578; Value.asString / format fall back to the filename for urlencoded / text-plain encodings. --- src/browser/webapi/net/FormData.zig | 188 +++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 5fd493a8..a7f1f891 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -47,14 +47,17 @@ pub const Entry = struct { fn asString(self: *const Value) []const u8 { return switch (self.*) { .string => |*s| s.str(), - .file => unreachable, // nothing currently creates this type of value + // Per WHATWG, file entries serialized in a non-multipart encoding + // (application/x-www-form-urlencoded, text/plain) collapse to the + // file's name only — body is dropped. + .file => |f| f.getName(), }; } pub fn format(self: Value, writer: *std.Io.Writer) !void { return switch (self) { .string => |s| s.format(writer), - .file => unreachable, // nothing currently creates this type of value + .file => |f| writer.writeAll(f.getName()), }; } }; @@ -244,8 +247,24 @@ fn multipartEncodeEntry(entry: *const Entry, boundary: []const u8, writer: *std. try writer.writeAll(s.str()); try writer.writeAll("\r\n"); }, - // File entries need a real payload (filename + bytes + Content-Type) — not yet wired. - .file => log.warn(.not_implemented, "FormData.multipart.file", .{}), + // Per RFC 7578 + WHATWG FormData: file parts carry a filename in + // Content-Disposition, a Content-Type (defaulting to + // application/octet-stream when the Blob has no MIME), and the raw bytes. + .file => |file| { + try writer.writeAll("Content-Disposition: form-data; name=\""); + try writeMultipartName(writer, entry.name.str()); + try writer.writeAll("\"; filename=\""); + try writeMultipartName(writer, file.getName()); + try writer.writeAll("\"\r\n"); + + const mime = file._proto._mime; + try writer.writeAll("Content-Type: "); + try writer.writeAll(if (mime.len == 0) "application/octet-stream" else mime); + try writer.writeAll("\r\n\r\n"); + + try writer.writeAll(file._proto._slice); + try writer.writeAll("\r\n"); + }, } } @@ -351,6 +370,21 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *F continue; } } + if (input_type == .file) { + // WHATWG: a file input with zero selected files contributes a + // single entry whose value is an empty File of MIME + // application/octet-stream; otherwise, one entry per file. + const files = if (input._files) |fl| fl._files else &.{}; + if (files.len == 0) { + const empty = try File.init(null, "", .{ .type = "application/octet-stream" }, frame._page); + try appendFile(&list, arena, name, empty); + } else { + for (files) |file| { + try appendFile(&list, arena, name, file); + } + } + continue; + } break :blk input.getValue(); } @@ -396,6 +430,13 @@ fn appendString(list: *std.ArrayList(Entry), arena: Allocator, name: []const u8, }); } +fn appendFile(list: *std.ArrayList(Entry), arena: Allocator, name: []const u8, file: *File) !void { + try list.append(arena, .{ + .name = try String.init(arena, name, .{}), + .value = .{ .file = file }, + }); +} + pub const JsApi = struct { pub const bridge = js.Bridge(FormData); @@ -493,6 +534,145 @@ test "FormData: multipart empty body" { try testing.expectString("--B--\r\n", buf.written()); } +const Blob = @import("../Blob.zig"); + +fn buildTestFile(arena: Allocator, page: *@import("../../Page.zig"), name: []const u8, mime: []const u8, body: []const u8) !*File { + const blob = try Blob.initFromBytes(body, mime, false, page); + blob.acquireRef(); + const file = try arena.create(File); + file.* = .{ + ._proto = blob, + ._name = try arena.dupe(u8, name), + ._last_modified = 0, + }; + return file; +} + +test "FormData: multipart with file" { + const allocator = testing.arena_allocator; + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + + const file = try buildTestFile(allocator, frame._page, "hello.txt", "text/plain", "hello"); + defer file._proto.releaseRef(frame._page); + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + try fd.append("field", "value"); + try fd._entries.append(allocator, .{ + .name = try String.init(allocator, "upload", .{}), + .value = .{ .file = file }, + }); + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ + .encoding = .{ .formdata = "BOUNDARY" }, + .allocator = allocator, + }, &buf.writer); + + try testing.expectString( + "--BOUNDARY\r\n" ++ + "Content-Disposition: form-data; name=\"field\"\r\n\r\n" ++ + "value\r\n" ++ + "--BOUNDARY\r\n" ++ + "Content-Disposition: form-data; name=\"upload\"; filename=\"hello.txt\"\r\n" ++ + "Content-Type: text/plain\r\n\r\n" ++ + "hello\r\n" ++ + "--BOUNDARY--\r\n", + buf.written(), + ); +} + +test "FormData: multipart with empty file defaults to octet-stream" { + const allocator = testing.arena_allocator; + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + + const file = try buildTestFile(allocator, frame._page, "", "", ""); + defer file._proto.releaseRef(frame._page); + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + try fd._entries.append(allocator, .{ + .name = try String.init(allocator, "upload", .{}), + .value = .{ .file = file }, + }); + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ + .encoding = .{ .formdata = "B" }, + .allocator = allocator, + }, &buf.writer); + + try testing.expectString( + "--B\r\n" ++ + "Content-Disposition: form-data; name=\"upload\"; filename=\"\"\r\n" ++ + "Content-Type: application/octet-stream\r\n\r\n" ++ + "\r\n" ++ + "--B--\r\n", + buf.written(), + ); +} + +test "FormData: multipart escapes file name and filename" { + const allocator = testing.arena_allocator; + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + + const file = try buildTestFile(allocator, frame._page, "a\"b\r\nc.txt", "text/plain", "x"); + defer file._proto.releaseRef(frame._page); + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + try fd._entries.append(allocator, .{ + .name = try String.init(allocator, "up\"load", .{}), + .value = .{ .file = file }, + }); + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ + .encoding = .{ .formdata = "B" }, + .allocator = allocator, + }, &buf.writer); + + try testing.expectString( + "--B\r\n" ++ + "Content-Disposition: form-data; name=\"up%22load\"; filename=\"a%22b%0D%0Ac.txt\"\r\n" ++ + "Content-Type: text/plain\r\n\r\n" ++ + "x\r\n" ++ + "--B--\r\n", + buf.written(), + ); +} + +test "FormData: file entry collapses to filename in urlencode" { + const allocator = testing.arena_allocator; + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + + const file = try buildTestFile(allocator, frame._page, "hello.txt", "text/plain", "hello"); + defer file._proto.releaseRef(frame._page); + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + try fd._entries.append(allocator, .{ + .name = try String.init(allocator, "upload", .{}), + .value = .{ .file = file }, + }); + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ .encoding = .urlencode, .allocator = allocator }, &buf.writer); + try testing.expectString("upload=hello.txt", buf.written()); +} + test "FormData: plaintext write" { const allocator = testing.arena_allocator; From 6ae3233f0e63020f4de96277f5a9fa6191c0353f Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 5 Jun 2026 15:55:56 +0200 Subject: [PATCH 2/7] form: fix no file selection case --- src/browser/webapi/net/FormData.zig | 67 ++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index a7f1f891..ef920a98 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -41,6 +41,7 @@ pub const Entry = struct { value: Value, const Value = union(enum) { + no_file: void, file: *File, string: String, @@ -51,6 +52,9 @@ pub const Entry = struct { // (application/x-www-form-urlencoded, text/plain) collapse to the // file's name only — body is dropped. .file => |f| f.getName(), + // An unselected file input contributes a File with an empty name, + // so it collapses to the empty string. + .no_file => "", }; } @@ -58,6 +62,7 @@ pub const Entry = struct { return switch (self) { .string => |s| s.format(writer), .file => |f| writer.writeAll(f.getName()), + .no_file => {}, }; } }; @@ -265,6 +270,17 @@ fn multipartEncodeEntry(entry: *const Entry, boundary: []const u8, writer: *std. try writer.writeAll(file._proto._slice); try writer.writeAll("\r\n"); }, + // An unselected file input still contributes a part: an empty filename, + // the default application/octet-stream Content-Type, and an empty body. + // This matches the empty File (no name, no type, no body) the WHATWG + // algorithm creates, and Chrome's wire output. + .no_file => { + try writer.writeAll("Content-Disposition: form-data; name=\""); + try writeMultipartName(writer, entry.name.str()); + try writer.writeAll("\"; filename=\"\"\r\n"); + try writer.writeAll("Content-Type: application/octet-stream\r\n\r\n"); + try writer.writeAll("\r\n"); + }, } } @@ -376,8 +392,10 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *F // application/octet-stream; otherwise, one entry per file. const files = if (input._files) |fl| fl._files else &.{}; if (files.len == 0) { - const empty = try File.init(null, "", .{ .type = "application/octet-stream" }, frame._page); - try appendFile(&list, arena, name, empty); + try list.append(arena, .{ + .name = try String.init(arena, name, .{}), + .value = .no_file, + }); } else { for (files) |file| { try appendFile(&list, arena, name, file); @@ -673,6 +691,51 @@ test "FormData: file entry collapses to filename in urlencode" { try testing.expectString("upload=hello.txt", buf.written()); } +test "FormData: multipart no_file (unselected file input)" { + const allocator = testing.arena_allocator; + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + try fd._entries.append(allocator, .{ + .name = try String.init(allocator, "upload", .{}), + .value = .no_file, + }); + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ + .encoding = .{ .formdata = "B" }, + .allocator = allocator, + }, &buf.writer); + + try testing.expectString( + "--B\r\n" ++ + "Content-Disposition: form-data; name=\"upload\"; filename=\"\"\r\n" ++ + "Content-Type: application/octet-stream\r\n\r\n" ++ + "\r\n" ++ + "--B--\r\n", + buf.written(), + ); +} + +test "FormData: no_file entry collapses to empty in urlencode" { + const allocator = testing.arena_allocator; + + var fd = FormData{ + ._arena = allocator, + ._entries = .empty, + }; + try fd._entries.append(allocator, .{ + .name = try String.init(allocator, "upload", .{}), + .value = .no_file, + }); + + var buf = std.Io.Writer.Allocating.init(allocator); + try fd.write(.{ .encoding = .urlencode, .allocator = allocator }, &buf.writer); + try testing.expectString("upload=", buf.written()); +} + test "FormData: plaintext write" { const allocator = testing.arena_allocator; From 8c1c3c93f837fb8c23ce3e7a54ada48dfdb1c293 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 5 Jun 2026 18:00:32 +0200 Subject: [PATCH 3/7] couple FormData and its Files lifetime --- src/browser/webapi/File.zig | 12 +++++++++ src/browser/webapi/net/FormData.zig | 40 ++++++++++++++++++++++++++++ src/browser/webapi/net/body_init.zig | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index ba493b6c..b71fcb90 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -60,6 +60,18 @@ pub fn init( return file; } +pub fn deinit(self: *File, page: *Page) void { + self._proto.deinit(page); +} + +pub fn releaseRef(self: *File, page: *Page) void { + self._proto.releaseRef(page); +} + +pub fn acquireRef(self: *File) void { + self._proto.acquireRef(); +} + pub fn getName(self: *const File) []const u8 { return self._name; } diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index ef920a98..f98b33bf 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); const Form = @import("../element/html/Form.zig"); const Element = @import("../Element.zig"); @@ -33,6 +34,8 @@ const Allocator = std.mem.Allocator; const FormData = @This(); +_rc: lp.RC(u32), + _arena: Allocator, _entries: std.ArrayList(Entry), @@ -71,6 +74,7 @@ pub const Entry = struct { pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormData { const form = form_ orelse { return try exec._factory.create(FormData{ + ._rc = .{}, ._arena = exec.arena, ._entries = .empty, }); @@ -90,6 +94,7 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD defer form._constructing_entry_list = false; const form_data = try exec._factory.create(FormData{ + ._rc = .{}, ._arena = exec.arena, ._entries = try collectForm(frame.arena, form, submitter, frame), }); @@ -104,6 +109,30 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD return form_data; } +pub fn deinit(_: *FormData, _: *Page) void {} + +pub fn releaseRef(self: *FormData, page: *Page) void { + self._rc.release(self, page); + + for (self._entries.items) |entry| { + switch (entry.value) { + .file => |file| file.releaseRef(page), + else => {}, + } + } +} + +pub fn acquireRef(self: *FormData) void { + self._rc.acquire(); + + for (self._entries.items) |entry| { + switch (entry.value) { + .file => |file| file.acquireRef(), + else => {}, + } + } +} + pub fn get(self: *const FormData, name: String) ?[]const u8 { for (self._entries.items) |*entry| { if (entry.name.eql(name)) { @@ -487,6 +516,7 @@ test "FormData: multipart write" { const allocator = testing.arena_allocator; var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -515,6 +545,7 @@ test "FormData: multipart escapes name CR/LF/quote" { const allocator = testing.arena_allocator; var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -539,6 +570,7 @@ test "FormData: multipart empty body" { const allocator = testing.arena_allocator; var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -575,6 +607,7 @@ test "FormData: multipart with file" { defer file._proto.releaseRef(frame._page); var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -612,6 +645,7 @@ test "FormData: multipart with empty file defaults to octet-stream" { defer file._proto.releaseRef(frame._page); var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -645,6 +679,7 @@ test "FormData: multipart escapes file name and filename" { defer file._proto.releaseRef(frame._page); var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -678,6 +713,7 @@ test "FormData: file entry collapses to filename in urlencode" { defer file._proto.releaseRef(frame._page); var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -695,6 +731,7 @@ test "FormData: multipart no_file (unselected file input)" { const allocator = testing.arena_allocator; var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -723,6 +760,7 @@ test "FormData: no_file entry collapses to empty in urlencode" { const allocator = testing.arena_allocator; var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -740,6 +778,7 @@ test "FormData: plaintext write" { const allocator = testing.arena_allocator; var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; @@ -765,6 +804,7 @@ test "FormData: plaintext empty body" { const allocator = testing.arena_allocator; var fd = FormData{ + ._rc = .{}, ._arena = allocator, ._entries = .empty, }; diff --git a/src/browser/webapi/net/body_init.zig b/src/browser/webapi/net/body_init.zig index adaa22f4..25ec72a1 100644 --- a/src/browser/webapi/net/body_init.zig +++ b/src/browser/webapi/net/body_init.zig @@ -161,7 +161,7 @@ test "BodyInit: FormData emits multipart with random boundary" { const arena = testing.arena_allocator; const fd = try arena.create(FormData); - fd.* = .{ ._arena = arena, ._entries = .empty }; + fd.* = .{ ._rc = .{}, ._arena = arena, ._entries = .empty }; try fd.append("username", "alice"); try fd.append("email", "alice@example.com"); From 4c06cfb17ce2befab53fffcc697a75869453cc10 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 8 Jun 2026 08:49:07 +0200 Subject: [PATCH 4/7] use u8 for reference counter Co-authored-by: Karl Seguin --- src/browser/webapi/net/FormData.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index f98b33bf..11686f87 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -34,7 +34,7 @@ const Allocator = std.mem.Allocator; const FormData = @This(); -_rc: lp.RC(u32), +_rc: lp.RC(u8), _arena: Allocator, _entries: std.ArrayList(Entry), From 6aa4ef074fac087c1b133af28826952f3f6f60ec Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 8 Jun 2026 08:51:01 +0200 Subject: [PATCH 5/7] call the ref count release after releasing coupled data --- src/browser/webapi/net/FormData.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 11686f87..41bb531c 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -112,14 +112,14 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD pub fn deinit(_: *FormData, _: *Page) void {} pub fn releaseRef(self: *FormData, page: *Page) void { - self._rc.release(self, page); - for (self._entries.items) |entry| { switch (entry.value) { .file => |file| file.releaseRef(page), else => {}, } } + + self._rc.release(self, page); } pub fn acquireRef(self: *FormData) void { From 845dac86bac754b170d5ceeb2bbc0f6ca578c248 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 8 Jun 2026 09:05:44 +0200 Subject: [PATCH 6/7] FormData: fix file leak on deletion --- src/browser/webapi/net/FormData.zig | 38 +++++++++++++++++------------ 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 41bb531c..cafbff3d 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -99,6 +99,13 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD ._entries = try collectForm(frame.arena, form, submitter, frame), }); + for (form_data._entries.items) |entry| { + switch (entry.value) { + .file => |file| file.acquireRef(), + else => {}, + } + } + const form_data_event = try (@import("../event/FormDataEvent.zig")).initTrusted( comptime .wrap("formdata"), .{ .bubbles = true, .cancelable = false, .formData = form_data }, @@ -109,28 +116,21 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD return form_data; } -pub fn deinit(_: *FormData, _: *Page) void {} - -pub fn releaseRef(self: *FormData, page: *Page) void { +pub fn deinit(self: *FormData, page: *Page) void { for (self._entries.items) |entry| { switch (entry.value) { .file => |file| file.releaseRef(page), else => {}, } } +} +pub fn releaseRef(self: *FormData, page: *Page) void { self._rc.release(self, page); } pub fn acquireRef(self: *FormData) void { self._rc.acquire(); - - for (self._entries.items) |entry| { - switch (entry.value) { - .file => |file| file.acquireRef(), - else => {}, - } - } } pub fn get(self: *const FormData, name: String) ?[]const u8 { @@ -161,8 +161,8 @@ pub fn has(self: *const FormData, name: String) bool { return false; } -pub fn set(self: *FormData, name: String, value: []const u8) !void { - self.deleteByName(name); +pub fn set(self: *FormData, name: String, value: []const u8, exec: *Execution) !void { + self.deleteByName(name, exec); return self.append(name.str(), value); } @@ -173,15 +173,21 @@ pub fn append(self: *FormData, name: []const u8, value: []const u8) !void { }); } -pub fn delete(self: *FormData, name: String) void { - self.deleteByName(name); +pub fn delete(self: *FormData, name: String, exec: *Execution) void { + self.deleteByName(name, exec); } -fn deleteByName(self: *FormData, name: String) void { +fn deleteByName(self: *FormData, name: String, exec: *Execution) void { var i: usize = 0; while (i < self._entries.items.len) { if (self._entries.items[i].name.eql(name)) { - _ = self._entries.swapRemove(i); + const entry = self._entries.swapRemove(i); + + switch (entry.value) { + .file => |file| file.releaseRef(exec.page), + else => {}, + } + continue; } i += 1; From 530a9e0765fd7c1da8f14c8aa0ff60bea6914485 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 8 Jun 2026 10:26:02 +0200 Subject: [PATCH 7/7] deinit FormData to release files --- src/browser/Frame.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index eea0b8aa..1bcb853a 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -4271,6 +4271,8 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For // The submitter can be an input box (if enter was entered on the box) // I don't think this is technically correct, but FormData handles it ok const form_data = try FormData.init(form, submitter_, &self.js.execution); + // FormData.init acquires file's references. So we must release them once done. + defer form_data.deinit(self._page); const arena = try self._session.getArena(.medium, "submitForm"); errdefer self._session.releaseArena(arena);