diff --git a/craft/src/utils/apply-ai-response.test.ts b/craft/src/utils/apply-ai-response.test.ts index 2853a62..26a361d 100644 --- a/craft/src/utils/apply-ai-response.test.ts +++ b/craft/src/utils/apply-ai-response.test.ts @@ -188,6 +188,36 @@ describe('applyPatch', () => { expect(propsById['craft-1'].style).toEqual({ color: 'blue', fontSize: '16px' }); }); + test('update_props: style: null does NOT wipe existing style (existing keys preserved)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', { + text: 'Old', + style: { color: 'red', fontSize: '16px' }, + }); + const ops = [ + { + op: 'update_props', + node_id: 'ai-node-1', + props: { style: null }, + }, + ]; + const result = __test.applyPatch(actions, query, ops); + expect(result.ok).toBe(true); + expect(propsById['craft-1'].style).toEqual({ color: 'red', fontSize: '16px' }); + warnSpy.mockRestore(); + }); + + test('update_props: a valid style object still merges shallowly alongside a style:null-safe path', () => { + const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', { + style: { color: 'red', fontSize: '16px' }, + }); + const ops = [ + { op: 'update_props', node_id: 'ai-node-1', props: { style: { color: 'blue' } } }, + ]; + __test.applyPatch(actions, query, ops); + expect(propsById['craft-1'].style).toEqual({ color: 'blue', fontSize: '16px' }); + }); + test('an op with an unknown resolvedName tree is skipped without throwing; other ops in the batch still apply', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', {}); diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index cfcc8cf..814208b 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -181,9 +181,16 @@ function applyPatch( // AI usually only means to change one or two style keys, and a // blind Object.assign would clobber every other style the user // (or a previous AI turn) had already set. - if (key === 'style' && value && typeof value === 'object' && !Array.isArray(value)) { - const existingStyle = p.style && typeof p.style === 'object' ? p.style : {}; - p.style = { ...existingStyle, ...(value as Record) }; + if (key === 'style') { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const existingStyle = p.style && typeof p.style === 'object' ? p.style : {}; + p.style = { ...existingStyle, ...(value as Record) }; + } else { + // `style: null` (or any other non-object) is not a valid + // style patch — ignore it rather than clobbering the whole + // style object with null/undefined/a stray primitive. + console.warn('sitesmith patch: update_props ignoring non-object "style" value', value); + } } else { p[key] = value; }