From 415182eb8e437a6dec3ea9e492e90aad91c0d786 Mon Sep 17 00:00:00 2001 From: roux-lon Date: Mon, 2 Nov 2026 12:41:47 +0700 Subject: [PATCH 1/2] fix(form-core): preserve File and Blob values in transform mergeAndUpdate deep-copies the form state before running a transform. deepCopy copied File and Blob values into empty plain objects, and the diff then wrote those objects back into the form, dropping attachments whenever a transform ran. Return Blob instances (including File) by reference instead. --- .changeset/preserve-file-in-transform.md | 5 ++ packages/form-core/src/utils.ts | 4 ++ packages/form-core/tests/transform.spec.ts | 60 +++++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 .changeset/preserve-file-in-transform.md diff --git a/.changeset/preserve-file-in-transform.md b/.changeset/preserve-file-in-transform.md new file mode 100644 index 0000000000..f8d94a90b4 --- /dev/null +++ b/.changeset/preserve-file-in-transform.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Preserve `File` and `Blob` values when a form `transform` runs. `mergeAndUpdate` deep-copied the form state and turned these values into empty plain objects, which dropped file attachments whenever a transform (for example merging server validation errors) ran. diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index e87296e418..e1229a36ee 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -700,6 +700,10 @@ export function deepCopy(obj: T): T { return new Date(obj.getTime()) as any } + if (typeof Blob !== 'undefined' && obj instanceof Blob) { + return obj + } + if (Array.isArray(obj)) { const arrCopy = [] as any[] for (let i = 0; i < obj.length; i++) { diff --git a/packages/form-core/tests/transform.spec.ts b/packages/form-core/tests/transform.spec.ts index f67b39352c..393e7d6351 100644 --- a/packages/form-core/tests/transform.spec.ts +++ b/packages/form-core/tests/transform.spec.ts @@ -1,4 +1,4 @@ -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { FieldApi, FormApi, mergeAndUpdate, mergeForm } from '../src' import type { AnyFormState } from '../src' @@ -34,3 +34,61 @@ it('transform option does not invalidate state for the field', () => { expect(ageField.state.meta.isValid).toBe(false) }) + +describe('transform preserves binary values', () => { + const makeFile = (name = 'attachment.txt') => + new File(['payload'], name, { type: 'text/plain' }) + + it('should preserve File values when a transform runs', () => { + const file = makeFile() + const form = new FormApi({ defaultValues: { file } }) + + mergeAndUpdate(form, (value) => value) + + expect(form.state.values.file).toBe(file) + }) + + it('should preserve Blob values when a transform runs', () => { + const blob = new Blob(['payload'], { type: 'text/plain' }) + const form = new FormApi({ defaultValues: { blob } }) + + mergeAndUpdate(form, (value) => value) + + expect(form.state.values.blob).toBe(blob) + }) + + it('should preserve File values nested in objects and arrays', () => { + const first = makeFile('first.txt') + const second = makeFile('second.txt') + const nested = makeFile('nested.txt') + const form = new FormApi({ + defaultValues: { + attachments: [first, second], + profile: { avatar: nested }, + }, + }) + + mergeAndUpdate(form, (value) => value) + + expect(form.state.values.attachments[0]).toBe(first) + expect(form.state.values.attachments[1]).toBe(second) + expect(form.state.values.profile.avatar).toBe(nested) + }) + + it('should preserve File values when a transform only merges errors', () => { + const file = makeFile() + const form = new FormApi({ + defaultValues: { age: 0, file }, + }) + form.mount() + + const state: Partial = { + errorMap: { onChange: { fields: { age: 'Age is required' } } }, + } + mergeAndUpdate(form, (f) => mergeForm(f, state)) + + expect(form.state.values.file).toBe(file) + expect(form.state.values.file.name).toBe('attachment.txt') + expect(form.state.values.file.size).toBe(7) + }) +}) From 56124bfaaeb45199c14791cf22977959fb9430ed Mon Sep 17 00:00:00 2001 From: roux-lon Date: Mon, 2 Nov 2026 13:17:33 +0700 Subject: [PATCH 2/2] fix(form-core): detect File and Blob by type tag in deepCopy Use Object.prototype.toString instead of instanceof so File and Blob values created in another realm (for example an iframe) are preserved too. Adds a test with a value that fails instanceof Blob. --- packages/form-core/src/utils.ts | 6 +++++- packages/form-core/tests/transform.spec.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index e1229a36ee..4557547fea 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -700,7 +700,11 @@ export function deepCopy(obj: T): T { return new Date(obj.getTime()) as any } - if (typeof Blob !== 'undefined' && obj instanceof Blob) { + // File extends Blob. Neither can be cloned property by property, so keep the + // original reference (they are immutable). Use the type tag instead of + // `instanceof` so values created in another realm (e.g. an iframe) match too. + const tag = Object.prototype.toString.call(obj) + if (tag === '[object Blob]' || tag === '[object File]') { return obj } diff --git a/packages/form-core/tests/transform.spec.ts b/packages/form-core/tests/transform.spec.ts index 393e7d6351..2dd310bd02 100644 --- a/packages/form-core/tests/transform.spec.ts +++ b/packages/form-core/tests/transform.spec.ts @@ -57,6 +57,21 @@ describe('transform preserves binary values', () => { expect(form.state.values.blob).toBe(blob) }) + it('should preserve File values that fail instanceof (e.g. other realms)', () => { + // A File created in another realm (such as an iframe) is not an instance of + // this realm's Blob, but still reports the same type tag. + const foreignFile = Object.create(null, { + [Symbol.toStringTag]: { value: 'File' }, + }) + expect(foreignFile instanceof Blob).toBe(false) + + const form = new FormApi({ defaultValues: { file: foreignFile } }) + + mergeAndUpdate(form, (value) => value) + + expect(form.state.values.file).toBe(foreignFile) + }) + it('should preserve File values nested in objects and arrays', () => { const first = makeFile('first.txt') const second = makeFile('second.txt')