diff --git a/.changeset/some-ducks-write.md b/.changeset/some-ducks-write.md new file mode 100644 index 000000000..fba5b9ec5 --- /dev/null +++ b/.changeset/some-ducks-write.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Fix File/Blob equality in change detection diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 71a6ddb69..08380484d 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -435,6 +435,18 @@ export function evaluate(objA: T, objB: T) { return false } + // Blob (and File, which extends Blob) objects have no own enumerable keys, + // so the generic key-comparison below would incorrectly consider any two + // Blob/File instances as equal. Fall back to referential identity (already + // handled by Object.is above, which returned false). + if ( + typeof Blob !== 'undefined' && + objA instanceof Blob && + objB instanceof Blob + ) { + return false + } + if (objA instanceof Date && objB instanceof Date) { return objA.getTime() === objB.getTime() } diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index c1b36a85c..6c6719f98 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -4101,6 +4101,32 @@ it('should generate a formId if not provided', () => { expect(form.formId.length).toBeGreaterThan(1) }) +it('should detect file value changes when setting a different File', () => { + const form = new FormApi({ + defaultValues: { + avatar: undefined as File | undefined, + }, + }) + + form.mount() + + const firstFile = new File(['first'], 'first.png', { type: 'image/png' }) + form.setFieldValue('avatar', firstFile) + expect(form.getFieldValue('avatar')).toBe(firstFile) + expect(form.getFieldValue('avatar')).toBeInstanceOf(File) + expect(form.getFieldValue('avatar')!.name).toBe('first.png') + + const secondFile = new File(['second'], 'second.png', { type: 'image/png' }) + form.setFieldValue('avatar', secondFile) + expect(form.getFieldValue('avatar')).toBe(secondFile) + expect(form.getFieldValue('avatar')).toBeInstanceOf(File) + expect(form.getFieldValue('avatar')!.name).toBe('second.png') + + // Setting the same file reference again should keep the same value + form.setFieldValue('avatar', secondFile) + expect(form.getFieldValue('avatar')).toBe(secondFile) +}) + describe('form api event client', () => { it('should have debug disabled', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index a545fd719..65b06b8c6 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -753,6 +753,37 @@ describe('evaluate', () => { const setB = new Set([1, 2, 4]) expect(evaluate(setA, setB)).toEqual(false) }) + + it('should test equality between File/Blob objects', () => { + // Same reference should be equal + const file1 = new File(['content'], 'test.txt', { type: 'text/plain' }) + expect(evaluate(file1, file1)).toEqual(true) + + // Different File objects with same metadata should NOT be equal + // (referential identity — the user picked a new file) + const file2 = new File(['content'], 'test.txt', { type: 'text/plain' }) + expect(evaluate(file1, file2)).toEqual(false) + + // Different File objects with different metadata + const file3 = new File(['other'], 'other.txt', { type: 'image/png' }) + expect(evaluate(file1, file3)).toEqual(false) + + // Blob objects + const blob1 = new Blob(['data'], { type: 'application/octet-stream' }) + const blob2 = new Blob(['data'], { type: 'application/octet-stream' }) + expect(evaluate(blob1, blob1)).toEqual(true) + expect(evaluate(blob1, blob2)).toEqual(false) + + // File inside an object structure + const obj1 = { avatar: file1 } + const obj2 = { avatar: file2 } + expect(evaluate(obj1, obj2)).toEqual(false) + + // Same file ref inside an object structure + const obj3 = { avatar: file1 } + const obj4 = { avatar: file1 } + expect(evaluate(obj3, obj4)).toEqual(true) + }) }) describe('concatenatePaths', () => {