From 522f9e7d9a4ec289d2d5519928d9b4fa313151b2 Mon Sep 17 00:00:00 2001 From: Samuel Laycock Date: Tue, 19 May 2026 20:51:47 +0100 Subject: [PATCH 1/2] fix: preserve blob values when files are preserved Treat Blob values like File values during plain-object encoding so callers can opt into preserving binary fields with files: preserve. Update the preserved entry type, docs, regression tests, and changeset for issue #66. --- .changeset/preserve-blob-values.md | 5 +++++ README.md | 8 ++++---- src/encode.ts | 13 ++++++++----- test/roundtrip.test.ts | 18 ++++++++++++++++-- test/types.test.ts | 2 +- 5 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 .changeset/preserve-blob-values.md diff --git a/.changeset/preserve-blob-values.md b/.changeset/preserve-blob-values.md new file mode 100644 index 0000000..c69088c --- /dev/null +++ b/.changeset/preserve-blob-values.md @@ -0,0 +1,5 @@ +--- +"superformdata": patch +--- + +Preserve plain object Blob values when encoding with `files: "preserve"`. diff --git a/README.md b/README.md index 1b58d68..24d15d1 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ decode([ ## File and Blob Entries -`superformdata` rejects `File` entries by default. This keeps binary uploads explicit and prevents accidental attempts to decode files as typed scalar fields. +`superformdata` rejects `File` and `Blob` entries by default. This keeps binary uploads explicit and prevents accidental attempts to decode binary values as typed scalar fields. Pass `{ files: "preserve" }` when you want to send typed scalar fields alongside file uploads. @@ -119,7 +119,7 @@ const entries = encode(formData, { files: "preserve", types: { views: "number" }, }); -// entries is typed as [string, string | File][] +// entries is typed as [string, string | File | Blob][] const value = decode<{ title: string; @@ -128,7 +128,7 @@ const value = decode<{ }>(entries, { files: "preserve" }); ``` -The same option works with `encode(form)` for `` controls, plain object `File` values, and `decodeRequest()` for multipart requests. Without `{ files: "preserve" }`, file inputs and file entries continue to throw. +The same option works with `encode(form)` for `` controls, plain object `File` and `Blob` values, and `decodeRequest()` for multipart requests. Without `{ files: "preserve" }`, file inputs and file/blob entries continue to throw. ## Decode a Request @@ -312,7 +312,7 @@ document.querySelector('input[name="homepage"]')?.addEventListener("change", onU ```ts encode(input: T, options?: { typesKey?: string; types?: Record; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): [string, string][] -encode(input: T, options: { files: "preserve"; typesKey?: string; types?: Record; typeHandlers?: readonly TypeHandler[] }): [string, string | File][] +encode(input: T, options: { files: "preserve"; typesKey?: string; types?: Record; typeHandlers?: readonly TypeHandler[] }): [string, string | File | Blob][] decode(data: FormData | Iterable<[string, FormDataEntryValue]>, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): T decodeRequest(request: Request, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): Promise onChange(typeId: string, options?: { typesKey?: string }): (event: Event) => void diff --git a/src/encode.ts b/src/encode.ts index 306f750..279959e 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -5,7 +5,7 @@ import { createTypeRegistry, findHandler, type TypeHandlerList } from "./types.t export const DEFAULT_TYPES_KEY = "$types"; export type EncodedEntry = [string, string]; -export type PreservedFileEntry = [string, string | File]; +export type PreservedFileEntry = [string, string | File | Blob]; export type FileStrategy = "throw" | "preserve"; export interface EncodeOptions { @@ -199,7 +199,7 @@ function encodeForm( } function encodeStringEntries( - data: Iterable<[string, string | File]>, + data: Iterable<[string, string | File | Blob]>, typesKey: string, registry: ReturnType, fileStrategy: FileStrategy, @@ -261,8 +261,11 @@ function trackRef(value: unknown, path: string, seen: Set): void { seen.add(value); } -function isFileValue(value: unknown): value is File { - return typeof File !== "undefined" && value instanceof File; +function isFileValue(value: unknown): value is File | Blob { + return ( + (typeof File !== "undefined" && value instanceof File) || + (typeof Blob !== "undefined" && value instanceof Blob) + ); } function assertDataPathNotReserved(path: string, typesKey: string): void { @@ -288,7 +291,7 @@ function walk( if (isFileValue(value)) { if (fileStrategy !== "preserve") { throw new TypeError( - `File values are not supported by superformdata at path "${path}". Handle file uploads separately or pass { files: "preserve" }.`, + `File and Blob values are not supported by superformdata at path "${path}". Handle file uploads separately or pass { files: "preserve" }.`, ); } entries.push([path, value]); diff --git a/test/roundtrip.test.ts b/test/roundtrip.test.ts index 3ddc031..96ce4d0 100644 --- a/test/roundtrip.test.ts +++ b/test/roundtrip.test.ts @@ -548,11 +548,25 @@ describe("encode/decode round-trip", () => { expect(encode({ attachment: file }, { files: "preserve" })).toEqual([["attachment", file]]); }); - test("encode throws a file-specific error for plain object File values by default", () => { + test('encode preserves Blob values in plain objects when files option is "preserve"', () => { + const blob = new Blob(["content"], { type: "text/plain" }); + + expect(encode({ attachment: blob }, { files: "preserve" })).toEqual([["attachment", blob]]); + }); + + test("encode throws a binary-specific error for plain object File values by default", () => { const file = new File(["content"], "test.txt"); expect(() => encode({ attachment: file })).toThrow( - 'File values are not supported by superformdata at path "attachment"', + 'File and Blob values are not supported by superformdata at path "attachment"', + ); + }); + + test("encode throws a binary-specific error for plain object Blob values by default", () => { + const blob = new Blob(["content"], { type: "text/plain" }); + + expect(() => encode({ attachment: blob })).toThrow( + 'File and Blob values are not supported by superformdata at path "attachment"', ); }); diff --git a/test/types.test.ts b/test/types.test.ts index 334b9a5..1bb3487 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -17,7 +17,7 @@ type _EncodeReturnIsStable = Assert>; const encodedWithFiles = encode(new FormData(), { files: "preserve" }); type _EncodePreserveFilesReturnIncludesFiles = Assert< - Equal + Equal >; const decoded = decode<{ From 1095851fc22a44b6c8e7b040b13348f80c9cbea9 Mon Sep 17 00:00:00 2001 From: Samuel Laycock Date: Tue, 19 May 2026 20:59:00 +0100 Subject: [PATCH 2/2] fix: accept blob entries in decode types Widen the public decode input type so encode output containing preserved Blob values can be passed directly to decode. Export the new entry aliases and add type coverage for the Blob encode-to-decode path. --- README.md | 2 +- src/core.ts | 2 +- src/decode.ts | 5 ++++- src/index.ts | 2 ++ test/roundtrip.test.ts | 8 ++++++++ test/types.test.ts | 7 +++++++ 6 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 24d15d1..03b0d6d 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ document.querySelector('input[name="homepage"]')?.addEventListener("change", onU ```ts encode(input: T, options?: { typesKey?: string; types?: Record; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): [string, string][] encode(input: T, options: { files: "preserve"; typesKey?: string; types?: Record; typeHandlers?: readonly TypeHandler[] }): [string, string | File | Blob][] -decode(data: FormData | Iterable<[string, FormDataEntryValue]>, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): T +decode(data: FormData | Iterable<[string, FormDataEntryValue | Blob]>, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): T decodeRequest(request: Request, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): Promise onChange(typeId: string, options?: { typesKey?: string }): (event: Event) => void ``` diff --git a/src/core.ts b/src/core.ts index 3b8a04e..9da19b6 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,5 +1,5 @@ export { decode, decodeRequest } from "./decode.ts"; -export type { DecodeOptions } from "./decode.ts"; +export type { DecodableEntry, DecodableEntryValue, DecodeOptions } from "./decode.ts"; export { encode } from "./encode.ts"; export type { EncodedEntry, diff --git a/src/decode.ts b/src/decode.ts index 03db46c..4874d56 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -31,8 +31,11 @@ export interface DecodeOptions { files?: "throw" | "preserve"; } +export type DecodableEntryValue = FormDataEntryValue | Blob; +export type DecodableEntry = [string, DecodableEntryValue]; + export function decode( - data: FormData | Iterable<[string, FormDataEntryValue]>, + data: FormData | Iterable, options?: DecodeOptions, ): T { const typesKey = options?.typesKey ?? DEFAULT_TYPES_KEY; diff --git a/src/index.ts b/src/index.ts index c0ea8fe..baca78d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export { decode, decodeRequest, encode, + type DecodableEntry, + type DecodableEntryValue, type DecodeOptions, type EncodedEntry, type EncodeOptions, diff --git a/test/roundtrip.test.ts b/test/roundtrip.test.ts index 96ce4d0..812379a 100644 --- a/test/roundtrip.test.ts +++ b/test/roundtrip.test.ts @@ -578,6 +578,14 @@ describe("encode/decode round-trip", () => { ); }); + test("encode throws when a preserved plain object Blob value uses the metadata key", () => { + const blob = new Blob(["content"], { type: "text/plain" }); + + expect(() => encode({ $types: blob }, { files: "preserve" })).toThrow( + 'Path "$types" is reserved for superformdata metadata', + ); + }); + test("encode rejects root string data fields that use the metadata key", () => { expect(() => encode({ $types: "user data" })).toThrow( 'Path "$types" is reserved for superformdata metadata; pass a different typesKey option or rename the root field.', diff --git a/test/types.test.ts b/test/types.test.ts index 1bb3487..139002b 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -20,6 +20,13 @@ type _EncodePreserveFilesReturnIncludesFiles = Assert< Equal >; +const blobEntries = encode({ attachment: new Blob(["content"]) }, { files: "preserve" }); +const decodedBlobEntries = decode<{ attachment: Blob }>(blobEntries, { files: "preserve" }); + +type _DecodeAcceptsEncodeOutputWithBlobEntries = Assert< + Equal +>; + const decoded = decode<{ createdAt: Date; active: boolean;