diff --git a/package.json b/package.json index 04218c167..868b3a0b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shineout", - "version": "2.0.32-beta.9", + "version": "2.0.32-beta.10", "description": "Shein 前端组件库", "main": "./lib/index.js", "module": "./es/index.js", diff --git a/site/pages/documentation/changelog/2.x.x.md b/site/pages/documentation/changelog/2.x.x.md index cb992859f..203e699d3 100644 --- a/site/pages/documentation/changelog/2.x.x.md +++ b/site/pages/documentation/changelog/2.x.x.md @@ -1,6 +1,7 @@ # 更新日志 ### 2.0.32 +- 修复 `Form` 表单值中包含 `File`、`Blob` 等特殊对象时,`getValue` / `setValue` 深克隆报错的问题 - 修复 `DatePicker`、`Select`、`TreeSelect`、`Cascader` 组件在禁用状态下仍会触发 onBlur / onFocus 回调的问题 - 修复 `Modal` 通过 `Modal.show` 等方法打开的弹窗,在 `onClose` 中调用 `Modal.closeAll` 时会触发无限递归导致栈溢出报错的问题 - 修复 `Form` 调用 `validateClear` 传入指定字段名时,若对应字段未注册校验器会导致报错的问题 diff --git a/src/Datum/Form.ts b/src/Datum/Form.ts index 98f955f20..1aedee540 100644 --- a/src/Datum/Form.ts +++ b/src/Datum/Form.ts @@ -1,8 +1,9 @@ import deepEqual from 'deep-eql' import { unflatten, insertValue, spliceValue, getSthByName } from '../utils/flat' -import { fastClone, deepClone } from '../utils/clone' +import { fastClone } from '../utils/clone' import { deepGet, deepSet, deepRemove, objectValues, deepHas } from '../utils/objects' import { isObject, isArray } from '../utils/is' +import { safeDeepClone } from '../utils/clone' import { promiseAll, FormError } from '../utils/errors' import { FormItemRule } from '../Rule/Props' import { @@ -250,7 +251,7 @@ export default class { } getValue() { - return deepClone(this.$values) + return safeDeepClone(this.$values) } setValue(v: any = {}, type?: typeof IGNORE_VALIDATE | typeof FORCE_PASS, forceSet?: boolean) { @@ -260,7 +261,7 @@ export default class { } // 兼容 value 传入 null 等错误等值 if (!forceSet && deepEqual(values, this.$values)) return - this.$values = deepClone(values) + this.$values = safeDeepClone(values) // wait render end. setTimeout(() => { diff --git a/src/index.js b/src/index.js index da8c2e0e2..e5b3acd51 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,7 @@ import * as utils from './utils' -export default { utils, version: '2.0.32-beta.9' } +export default { utils, version: '2.0.32-beta.10' } export { utils } export { setLocale } from './locale' export { color, style } from './utils/expose' diff --git a/test/src/Datum/Form.file.spec.js b/test/src/Datum/Form.file.spec.js new file mode 100644 index 000000000..54928c0f5 --- /dev/null +++ b/test/src/Datum/Form.file.spec.js @@ -0,0 +1,100 @@ +import Datum from '../../../src/Datum' + +describe('Form with File in value', () => { + it('should setValue/getValue with File without error', () => { + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + const data = { + name: 'hello', + age: 18, + file, + } + + const datum = new Datum.Form() + // setValue 不应报错 + expect(() => datum.setValue(data)).not.toThrow() + // getValue 不应报错 + const result = datum.getValue() + + // file 字段应保持为同一个 File 实例 + expect(result.file).toBe(file) + expect(result.file instanceof File).toBe(true) + expect(result.file.name).toBe('test.txt') + expect(result.file.type).toBe('text/plain') + expect(result.file.size).toBe(7) // 'content'.length + + // 其他字段正常深克隆 + expect(result.name).toBe('hello') + expect(result.age).toBe(18) + expect(result).not.toBe(data) + }) + + it('should preserve File after set single field', () => { + const file = new File(['img'], 'photo.png', { type: 'image/png' }) + const data = { name: 'test', file } + + const mockSetter = jest.fn() + const datum = new Datum.Form({ onChange: mockSetter }) + datum.setValue(data) + + // 通过 set 修改其他字段 + datum.set('name', 'updated') + + // onChange 回调中的值应保持 file 引用 + const changed = mockSetter.mock.calls[0][0] + expect(changed.file).toBe(file) + expect(changed.file.name).toBe('photo.png') + expect(changed.name).toBe('updated') + }) + + it('should preserve File through multiple getValue calls', () => { + const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' }) + const datum = new Datum.Form() + datum.setValue({ file, title: 'report' }) + + const val1 = datum.getValue() + const val2 = datum.getValue() + + // 每次 getValue 返回不同对象,但 file 始终是同一引用 + expect(val1).not.toBe(val2) + expect(val1.file).toBe(file) + expect(val2.file).toBe(file) + expect(val1.file).toBe(val2.file) + }) + + it('should handle multiple Files in array field', () => { + const file1 = new File(['a'], '1.txt', { type: 'text/plain' }) + const file2 = new File(['b'], '2.txt', { type: 'text/plain' }) + const data = { files: [file1, file2], desc: 'batch upload' } + + const datum = new Datum.Form() + datum.setValue(data) + const result = datum.getValue() + + expect(result.files[0]).toBe(file1) + expect(result.files[1]).toBe(file2) + expect(result.files).not.toBe(data.files) // 数组本身被克隆 + expect(result.desc).toBe('batch upload') + }) + + it('should not break deepEqual check with File', () => { + const file = new File(['x'], 'x.bin', { type: 'application/octet-stream' }) + const data = { file, count: 1 } + + const datum = new Datum.Form() + datum.setValue(data) + + const valueBefore = datum.getValue() + + // 再次 setValue 相同内容,deepEqual 应判定相等,$values 不变 + datum.setValue({ file, count: 1 }) + const valueAfterSame = datum.getValue() + // deepEqual 判定相等时 $values 不重新赋值,引用不变 + expect(valueAfterSame).toEqual(valueBefore) + + // 修改非 file 字段,应更新 + datum.setValue({ file, count: 2 }) + const valueAfterDiff = datum.getValue() + expect(valueAfterDiff.count).toBe(2) + expect(valueAfterDiff.file).toBe(file) + }) +}) diff --git a/test/utils/safeDeepClone.spec.js b/test/utils/safeDeepClone.spec.js new file mode 100644 index 000000000..d8557bd54 --- /dev/null +++ b/test/utils/safeDeepClone.spec.js @@ -0,0 +1,153 @@ +import { safeDeepClone } from '../../src/utils/clone' + +describe('clone.js[safeDeepClone]', () => { + // 基础类型 + it('should return primitives as-is', () => { + expect(safeDeepClone(null)).toBeNull() + expect(safeDeepClone(undefined)).toBeUndefined() + expect(safeDeepClone(1)).toBe(1) + expect(safeDeepClone('hello')).toBe('hello') + expect(safeDeepClone(true)).toBe(true) + expect(safeDeepClone(NaN)).toBeNaN() + }) + + // 纯对象深克隆 + it('should deep clone plain object', () => { + const source = { a: 1, b: { c: 2, d: [3, 4] } } + const result = safeDeepClone(source) + expect(result).toEqual(source) + expect(result).not.toBe(source) + expect(result.b).not.toBe(source.b) + expect(result.b.d).not.toBe(source.b.d) + }) + + // 数组深克隆 + it('should deep clone arrays', () => { + const source = [1, { a: 2 }, [3, 4]] + const result = safeDeepClone(source) + expect(result).toEqual(source) + expect(result).not.toBe(source) + expect(result[1]).not.toBe(source[1]) + expect(result[2]).not.toBe(source[2]) + }) + + // Date 克隆 + it('should clone Date objects', () => { + const date = new Date('2024-01-01') + const result = safeDeepClone(date) + expect(result).toEqual(date) + expect(result).not.toBe(date) + expect(result.getTime()).toBe(date.getTime()) + }) + + // RegExp 克隆 + it('should clone RegExp objects', () => { + const reg = /hello/gi + const result = safeDeepClone(reg) + expect(result).not.toBe(reg) + expect(result.source).toBe(reg.source) + expect(result.flags).toBe(reg.flags) + }) + + // 核心场景:File 对象不报错,返回原引用 + it('should handle File objects without throwing', () => { + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + const source = { name: 'form', file } + const result = safeDeepClone(source) + expect(result).not.toBe(source) + expect(result.file).toBe(file) // File 返回原引用,不尝试克隆 + expect(result.name).toBe('form') + }) + + it('should handle File in nested structure', () => { + const file = new File(['data'], 'upload.png', { type: 'image/png' }) + const source = { + user: { name: 'test' }, + uploads: [{ id: 1, file }], + } + const result = safeDeepClone(source) + expect(result.uploads[0].file).toBe(file) + expect(result.user).not.toBe(source.user) + expect(result.uploads).not.toBe(source.uploads) + }) + + // 循环引用保护 + it('should handle circular references', () => { + const source = { a: 1 } + source.self = source + const result = safeDeepClone(source) + expect(result.a).toBe(1) + expect(result.self).toBe(result) // 循环引用指向克隆后的对象 + expect(result).not.toBe(source) + }) + + // React Element 保护 + it('should return React elements as-is', () => { + const element = { $$typeof: Symbol.for('react.element'), type: 'div', props: {} } + const result = safeDeepClone(element) + expect(result).toBe(element) + }) + + // DOM Node 保护 + it('should return DOM nodes as-is', () => { + const node = document.createElement('div') + const result = safeDeepClone(node) + expect(result).toBe(node) + }) + + // 自定义 class 实例返回引用 + it('should return class instances as-is', () => { + class MyModel { + constructor(id) { + this.id = id + } + } + const instance = new MyModel(1) + const source = { model: instance, name: 'test' } + const result = safeDeepClone(source) + expect(result.model).toBe(instance) // class 实例返回原引用 + expect(result).not.toBe(source) + expect(result.name).toBe('test') + }) + + // 模拟 Form 实际使用场景 + it('should work with typical Form values containing File', () => { + const file = new File(['hello'], 'doc.pdf', { type: 'application/pdf' }) + const formValues = { + email: 'test@example.com', + age: 18, + address: { city: 'Shanghai', zip: '200000' }, + tags: ['a', 'b'], + startDate: new Date('2024-06-01'), + avatar: file, + } + const result = safeDeepClone(formValues) + + // 纯对象和数组被深克隆 + expect(result).not.toBe(formValues) + expect(result.address).not.toBe(formValues.address) + expect(result.tags).not.toBe(formValues.tags) + expect(result.startDate).not.toBe(formValues.startDate) + + // 值相等 + expect(result.email).toBe('test@example.com') + expect(result.age).toBe(18) + expect(result.address).toEqual({ city: 'Shanghai', zip: '200000' }) + expect(result.tags).toEqual(['a', 'b']) + expect(result.startDate.getTime()).toBe(formValues.startDate.getTime()) + + // File 保持引用,不报错 + expect(result.avatar).toBe(file) + }) + + // Object.create(null) 对象 + it('should clone Object.create(null) objects', () => { + const source = Object.create(null) + source.a = 1 + source.b = { c: 2 } + const result = safeDeepClone(source) + expect(result.a).toBe(1) + expect(result.b).toEqual({ c: 2 }) + expect(result.b).not.toBe(source.b) + }) +})