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..4557547fea 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -700,6 +700,14 @@ export function deepCopy(obj: T): T { return new Date(obj.getTime()) as any } + // 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 + } + 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..2dd310bd02 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,76 @@ 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 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') + 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) + }) +})