diff --git a/packages/kit/src/runtime/state/edit.ts b/packages/kit/src/runtime/state/edit.ts index 0fbbf7a05..c68811936 100644 --- a/packages/kit/src/runtime/state/edit.ts +++ b/packages/kit/src/runtime/state/edit.ts @@ -57,7 +57,8 @@ export function setStateValue( if (newKey && newKey !== field) { const raw = toRaw(unwrapped) if (Array.isArray(raw) || raw instanceof Set) return false - if (raw instanceof Map ? raw.has(newKey) : hasOwn(unwrapped as object, newKey)) return false + if (raw instanceof Map ? hasMapPreviewKey(raw, newKey) : hasOwn(unwrapped as object, newKey)) + return false } const written = writeEditableValue(unwrapped, field, value) if (!written || !newKey || newKey === field) return written @@ -89,6 +90,13 @@ export function addStateValue(target: object, path: string[], value: unknown): b return addEditableValue(unwrapEditableValue(object), value) } +function hasMapPreviewKey(target: Map, field: string): boolean { + for (const key of target.keys()) { + if (formatPreviewKey(key) === field) return true + } + return false +} + function resolveMapKey( target: Map, field: string, @@ -201,7 +209,7 @@ function renameEditableValue(target: unknown, previousField: string, nextField: const rawTarget = toRaw(target) if (rawTarget instanceof Map) { const key = resolveMapKey(rawTarget, previousField) - if (!key.found || rawTarget.has(nextField)) return false + if (!key.found || hasMapPreviewKey(rawTarget, nextField)) return false const collection = target as Map const value = collection.get(key.key) collection.delete(key.key) @@ -300,8 +308,9 @@ function getNextAvailableObjectKey(target: object): string { } function getNextAvailableMapKey(target: Map): string { + const labels = new Set(Array.from(target.keys(), formatPreviewKey)) for (let i = 1; ; i++) { const key = `newProp${i}` - if (!target.has(key)) return key + if (!labels.has(key)) return key } } diff --git a/tests/unit/runtime/state-edit.test.ts b/tests/unit/runtime/state-edit.test.ts index a4867528b..285caa8da 100644 --- a/tests/unit/runtime/state-edit.test.ts +++ b/tests/unit/runtime/state-edit.test.ts @@ -73,6 +73,68 @@ describe('collection state editing', () => { ]) }) + it.each([ + { key: 1, label: '1' }, + { key: true, label: 'true' }, + { key: 1n, label: '1' }, + { key: Symbol('key'), label: 'key' }, + { key: { id: 1 }, label: 'Object' }, + { key: null, label: 'null' }, + { key: undefined, label: 'undefined' }, + ])('rejects renaming to an existing key label $label before writing', ({ key, label }) => { + const map = new Map([ + [key, 'existing'], + ['source', 'original'], + ]) + + expect(setStateValue({ map }, ['map', 'source'], 'replacement', label)).toBe(false) + expect([...map]).toEqual([ + [key, 'existing'], + ['source', 'original'], + ]) + }) + + it('does not notify reactive effects when a rename would create a duplicate label', () => { + const map = reactive( + new Map([ + [1, 'existing'], + ['source', 'original'], + ]), + ) + let updates = 0 + effect(() => { + map.get('source') + updates++ + }) + + expect(setStateValue({ map }, ['map', 'source'], 'replacement', '1')).toBe(false) + expect(updates).toBe(1) + expect(map.get('source')).toBe('original') + }) + + it('still renames a non-string Map key to an unused label', () => { + const map = new Map([[1, 'original']]) + + expect(setStateValue({ map }, ['map', '1'], 'replacement', 'count')).toBe(true) + expect([...map]).toEqual([['count', 'replacement']]) + }) + + it('skips existing preview labels when generating a new Map key', () => { + const key = Symbol('newProp1') + const map = new Map([ + [key, 'symbol'], + ['newProp2', 'string'], + ]) + + expect(addStateValue({ map }, ['map'], 'added')).toBe(true) + expect([...map]).toEqual([ + [key, 'symbol'], + ['newProp2', 'string'], + ['newProp3', 'added'], + ]) + expect(resolvePath({ map }, ['map', 'newProp1'])).toEqual({ found: true, value: 'symbol' }) + }) + it.each(['native', 'legacy'])('notifies effects through %s collection edits', (mode) => { const map = reactive(new Map([['x', 1]])) const set = reactive(new Set([1]))