diff --git a/packages/core/src/history/CommandHistory.test.ts b/packages/core/src/history/CommandHistory.test.ts index 65906958a..52b2137c4 100644 --- a/packages/core/src/history/CommandHistory.test.ts +++ b/packages/core/src/history/CommandHistory.test.ts @@ -199,4 +199,29 @@ describe('CommandHistory', () => { expect(ch.getAll()).toHaveLength(3); expect(ch.getAll()).toEqual(smallHistory); }); + + it('import() is a no-op for malformed JSON', () => { + const ch = new CommandHistory(); + ch.add('existing'); + ch.import('not valid json {{{'); + expect(ch.getAll()).toEqual(['existing']); + }); + + it('import() is a no-op for non-array JSON values', () => { + const ch = new CommandHistory(); + ch.add('existing'); + ch.import(JSON.stringify({ commands: ['cmd1', 'cmd2'] })); + expect(ch.getAll()).toEqual(['existing']); + ch.import(JSON.stringify(42)); + expect(ch.getAll()).toEqual(['existing']); + ch.import(JSON.stringify(null)); + expect(ch.getAll()).toEqual(['existing']); + }); + + it('import() is a no-op for arrays containing non-string elements', () => { + const ch = new CommandHistory(); + ch.add('existing'); + ch.import(JSON.stringify(['valid', 123, 'also valid'] as unknown[])); + expect(ch.getAll()).toEqual(['existing']); + }); }); \ No newline at end of file diff --git a/packages/core/src/history/CommandHistory.ts b/packages/core/src/history/CommandHistory.ts index 20fc4d216..9f420a828 100644 --- a/packages/core/src/history/CommandHistory.ts +++ b/packages/core/src/history/CommandHistory.ts @@ -99,7 +99,15 @@ export class CommandHistory { /** Restore history from a JSON string produced by {@link export}. */ import(data: string): void { - const parsed: string[] = JSON.parse(data); + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return; + } + if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string')) { + return; + } this.commands = parsed.slice(-this.maxSize); this.index = this.commands.length; } diff --git a/packages/motion/src/interpolate.ts b/packages/motion/src/interpolate.ts index 0d1c4866c..c4794bdca 100644 --- a/packages/motion/src/interpolate.ts +++ b/packages/motion/src/interpolate.ts @@ -21,6 +21,9 @@ export function mapRange( const clamp = options?.clamp ?? true; if (inMin === inMax) { + if (clamp && outMin !== outMax) { + return Math.min(outMin, outMax); + } return outMin; } diff --git a/packages/store/src/immutable.test.ts b/packages/store/src/immutable.test.ts index 47ed8762e..01afccbae 100644 --- a/packages/store/src/immutable.test.ts +++ b/packages/store/src/immutable.test.ts @@ -43,6 +43,17 @@ describe('immutable helpers', () => { expect(updated).toEqual({ list: [1, 20, 3] }); expect(original.list).toEqual([1, 2, 3]); // should not mutate }); + + it('setIn from null builds nested path correctly', () => { + const updated = setIn(null, ['a', 'b'], 'value'); + expect(updated).toEqual({ a: { b: 'value' } }); + }); + + it('setIn from null with numeric index creates array', () => { + const updated = setIn(null, ['a', 0, 'b'], 'value'); + expect(updated).toEqual({ a: [{ b: 'value' }] }); + expect(Array.isArray((updated as any).a)).toBe(true); + }); }); describe('updateIn', () => { diff --git a/packages/store/src/immutable.ts b/packages/store/src/immutable.ts index c0acdc5ae..d406a7775 100644 --- a/packages/store/src/immutable.ts +++ b/packages/store/src/immutable.ts @@ -24,13 +24,47 @@ export function setIn(obj: T, path: (string | number)[], value: any): T { clone = [...obj]; } else if (obj !== null && typeof obj === 'object') { clone = { ...obj }; + // If clone is effectively empty (from spreading {} or null-ish) and + // the next key is numeric, replace with an array so that clone[0] = x + // creates a real array element instead of a string-keyed property. + if (clone && typeof key === 'number' && Object.keys(clone).length === 0) { + clone = []; + } } else { + // obj is null or a primitive; create the appropriate container + // based on the type of the next key to construct the nested path clone = typeof key === 'number' ? [] : {}; } - const nextVal = rest.length > 0 - ? setIn(clone[key], rest, value) - : value; + // When recursing, determine the correct container for the next level. + // Reuse an existing non-empty object/array to preserve sibling values. + // Otherwise create a container matching the key type: array for numeric keys, + // object for string keys. + const existing = clone[key]; + const isNonEmptyContainer = existing !== undefined + && existing !== null + && typeof existing === 'object' + && Object.keys(existing).length > 0; + + // For the terminal step (rest.length === 0) with a string key and an empty + // container, set the value directly on clone. If clone is an array and the key + // is a string, convert to a plain object first so the property survives + // JSON serialization; for numeric keys the array form is correct. + if (rest.length === 0) { + if (typeof key !== 'number' && Array.isArray(clone)) { + // Convert array to plain object to preserve the string-keyed property + const converted: any = {}; + for (const k of Object.keys(clone)) converted[k] = clone[k as any]; + clone = converted; + } + clone[key] = value; + return clone as T; + } + + const container = isNonEmptyContainer + ? existing + : (typeof key === 'number' ? [] : {}); + const nextVal = setIn(container, rest, value); clone[key] = nextVal; return clone as T;