Skip to content
Open
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-file-in-transform.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,14 @@ export function deepCopy<T>(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]') {
Comment thread
roux-lon marked this conversation as resolved.
Resolved
return obj
}

if (Array.isArray(obj)) {
const arrCopy = [] as any[]
for (let i = 0; i < obj.length; i++) {
Expand Down
75 changes: 74 additions & 1 deletion packages/form-core/tests/transform.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<AnyFormState> = {
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)
})
})