Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preserve-blob-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"superformdata": patch
---

Preserve plain object Blob values when encoding with `files: "preserve"`.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand All @@ -128,7 +128,7 @@ const value = decode<{
}>(entries, { files: "preserve" });
```

The same option works with `encode(form)` for `<input type="file">` 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 `<input type="file">` 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

Expand Down Expand Up @@ -312,8 +312,8 @@ document.querySelector('input[name="homepage"]')?.addEventListener("change", onU

```ts
encode<T>(input: T, options?: { typesKey?: string; types?: Record<string, string>; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): [string, string][]
encode<T>(input: T, options: { files: "preserve"; typesKey?: string; types?: Record<string, string>; typeHandlers?: readonly TypeHandler[] }): [string, string | File][]
decode<T = unknown>(data: FormData | Iterable<[string, FormDataEntryValue]>, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): T
encode<T>(input: T, options: { files: "preserve"; typesKey?: string; types?: Record<string, string>; typeHandlers?: readonly TypeHandler[] }): [string, string | File | Blob][]
decode<T = unknown>(data: FormData | Iterable<[string, FormDataEntryValue | Blob]>, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): T
decodeRequest<T = unknown>(request: Request, options?: { typesKey?: string; typeHandlers?: readonly TypeHandler[]; files?: "throw" | "preserve" }): Promise<T>
onChange(typeId: string, options?: { typesKey?: string }): (event: Event) => void
```
Expand Down
2 changes: 1 addition & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ export interface DecodeOptions {
files?: "throw" | "preserve";
}

export type DecodableEntryValue = FormDataEntryValue | Blob;
export type DecodableEntry = [string, DecodableEntryValue];

export function decode<T = unknown>(
data: FormData | Iterable<[string, FormDataEntryValue]>,
data: FormData | Iterable<DecodableEntry>,
options?: DecodeOptions,
): T {
const typesKey = options?.typesKey ?? DEFAULT_TYPES_KEY;
Expand Down
13 changes: 8 additions & 5 deletions src/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Comment thread
samlaycock marked this conversation as resolved.
export type FileStrategy = "throw" | "preserve";

export interface EncodeOptions {
Expand Down Expand Up @@ -199,7 +199,7 @@ function encodeForm(
}

function encodeStringEntries(
data: Iterable<[string, string | File]>,
data: Iterable<[string, string | File | Blob]>,
typesKey: string,
registry: ReturnType<typeof createTypeRegistry>,
fileStrategy: FileStrategy,
Expand Down Expand Up @@ -261,8 +261,11 @@ function trackRef(value: unknown, path: string, seen: Set<unknown>): 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 {
Expand All @@ -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]);
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export {
decode,
decodeRequest,
encode,
type DecodableEntry,
type DecodableEntryValue,
type DecodeOptions,
type EncodedEntry,
type EncodeOptions,
Expand Down
26 changes: 24 additions & 2 deletions test/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
);
});

Expand All @@ -564,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.',
Expand Down
9 changes: 8 additions & 1 deletion test/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ type _EncodeReturnIsStable = Assert<Equal<typeof encoded, [string, string][]>>;
const encodedWithFiles = encode(new FormData(), { files: "preserve" });

type _EncodePreserveFilesReturnIncludesFiles = Assert<
Equal<typeof encodedWithFiles, [string, string | File][]>
Equal<typeof encodedWithFiles, [string, string | File | Blob][]>
>;

const blobEntries = encode({ attachment: new Blob(["content"]) }, { files: "preserve" });
const decodedBlobEntries = decode<{ attachment: Blob }>(blobEntries, { files: "preserve" });

type _DecodeAcceptsEncodeOutputWithBlobEntries = Assert<
Equal<typeof decodedBlobEntries, { attachment: Blob }>
>;

const decoded = decode<{
Expand Down
Loading