Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions site/pages/documentation/changelog/2.x.x.md
Original file line number Diff line number Diff line change
@@ -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` 传入指定字段名时,若对应字段未注册校验器会导致报错的问题
Expand Down
7 changes: 4 additions & 3 deletions src/Datum/Form.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -250,7 +251,7 @@ export default class<V extends ObjectType> {
}

getValue() {
return deepClone(this.$values)
return safeDeepClone(this.$values)
}

setValue(v: any = {}, type?: typeof IGNORE_VALIDATE | typeof FORCE_PASS, forceSet?: boolean) {
Expand All @@ -260,7 +261,7 @@ export default class<V extends ObjectType> {
}
// 兼容 value 传入 null 等错误等值
if (!forceSet && deepEqual(values, this.$values)) return
this.$values = deepClone(values)
this.$values = safeDeepClone(values)

// wait render end.
setTimeout(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
100 changes: 100 additions & 0 deletions test/src/Datum/Form.file.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
153 changes: 153 additions & 0 deletions test/utils/safeDeepClone.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading