diff --git a/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/__tests__/applySiblingStyle.test.ts b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/__tests__/applySiblingStyle.test.ts new file mode 100644 index 000000000..0f8b0b103 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/__tests__/applySiblingStyle.test.ts @@ -0,0 +1,82 @@ +import { applySiblingStyleId } from '../applySiblingStyle'; +import * as getSiblingStyleId from '../getSiblingStyleId'; + +describe('applySiblingStyleId', () => { + const getNewStyleIdSpy = jest.spyOn(getSiblingStyleId, 'getNewStyleId'); + + beforeEach(() => { + getNewStyleIdSpy.mockReset(); + }); + + it('should swap styles and recurse into children for a FRAME', async () => { + const mockChild = { + type: 'RECTANGLE', + fillStyleId: 'style-1', + }; + const mockNode = { + type: 'FRAME', + children: [mockChild], + fillStyleId: 'style-2', + } as any; + + getNewStyleIdSpy.mockResolvedValue('new-style'); + + await applySiblingStyleId(mockNode, {}, {}, []); + + expect(getNewStyleIdSpy).toHaveBeenCalledWith('style-2', {}, {}, []); + expect(getNewStyleIdSpy).toHaveBeenCalledWith('style-1', {}, {}, []); + expect(mockNode.fillStyleId).toBe('new-style'); + expect(mockChild.fillStyleId).toBe('new-style'); + }); + + it('should skip INSTANCE children but process the COMPONENT itself', async () => { + const mockInstanceChild = { + type: 'INSTANCE', + fillStyleId: 'style-instance', + }; + const mockRectChild = { + type: 'RECTANGLE', + fillStyleId: 'style-rect', + }; + const mockNode = { + type: 'COMPONENT', + children: [mockInstanceChild, mockRectChild], + fillStyleId: 'style-comp', + } as any; + + getNewStyleIdSpy.mockResolvedValue('new-style'); + + await applySiblingStyleId(mockNode, {}, {}, []); + + // Should be called for COMPONENT and RECTANGLE, but NOT INSTANCE child + expect(getNewStyleIdSpy).toHaveBeenCalledWith('style-comp', {}, {}, []); + expect(getNewStyleIdSpy).toHaveBeenCalledWith('style-rect', {}, {}, []); + expect(getNewStyleIdSpy).not.toHaveBeenCalledWith('style-instance', {}, {}, []); + + expect(mockNode.fillStyleId).toBe('new-style'); + expect(mockRectChild.fillStyleId).toBe('new-style'); + expect(mockInstanceChild.fillStyleId).toBe('style-instance'); // Should remain unchanged + }); + + it('should skip INSTANCE children but process the COMPONENT_SET itself', async () => { + const mockInstanceChild = { + type: 'INSTANCE', + fillStyleId: 'style-instance', + }; + const mockNode = { + type: 'COMPONENT_SET', + children: [mockInstanceChild], + fillStyleId: 'style-comp-set', + } as any; + + getNewStyleIdSpy.mockResolvedValue('new-style'); + + await applySiblingStyleId(mockNode, {}, {}, []); + + expect(getNewStyleIdSpy).toHaveBeenCalledWith('style-comp-set', {}, {}, []); + expect(getNewStyleIdSpy).not.toHaveBeenCalledWith('style-instance', {}, {}, []); + + expect(mockNode.fillStyleId).toBe('new-style'); + expect(mockInstanceChild.fillStyleId).toBe('style-instance'); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/applySiblingStyle.ts b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/applySiblingStyle.ts index 240b21a7a..c8f2a71a1 100644 --- a/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/applySiblingStyle.ts +++ b/packages/tokens-studio-for-figma/src/plugin/asyncMessageHandlers/applySiblingStyle.ts @@ -78,7 +78,12 @@ export async function applySiblingStyleId(node: BaseNode, styleIds: StyleIdMap, node.effectStyleId = newEffectStyleId; } if (['COMPONENT', 'COMPONENT_SET', 'SECTION', 'INSTANCE', 'FRAME', 'BOOLEAN_OPERATION'].includes(node.type) && 'children' in node) { - await Promise.all(node.children.map((child) => applySiblingStyleId(child, styleIds, styleMap, activeThemes))); + await Promise.all(node.children.map((child) => { + if ((node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') && child.type === 'INSTANCE') { + return Promise.resolve(); + } + return applySiblingStyleId(child, styleIds, styleMap, activeThemes); + })); } } break; diff --git a/packages/tokens-studio-for-figma/src/utils/__tests__/findAll.test.ts b/packages/tokens-studio-for-figma/src/utils/__tests__/findAll.test.ts new file mode 100644 index 000000000..de7139049 --- /dev/null +++ b/packages/tokens-studio-for-figma/src/utils/__tests__/findAll.test.ts @@ -0,0 +1,94 @@ +import { ValidNodeTypes } from '@/constants/ValidNodeTypes'; +import { findAll } from '../findAll'; + +describe('findAll', () => { + it('should find all nodes including self', () => { + const mockNodes = [ + { + id: '1', + type: 'FRAME', + children: [ + { id: '2', type: 'RECTANGLE' }, + ], + findAllWithCriteria: jest.fn().mockReturnValue([{ id: '2', type: 'RECTANGLE' }]), + }, + ] as any; + + const result = findAll(mockNodes, true); + expect(result).toHaveLength(2); + expect(result.map(n => n.id)).toContain('1'); + expect(result.map(n => n.id)).toContain('2'); + }); + + it('should exclude instances when recursing from a COMPONENT', () => { + const mockNodes = [ + { + id: 'component-1', + type: 'COMPONENT', + children: [ + { id: 'rect-1', type: 'RECTANGLE' }, + { id: 'instance-1', type: 'INSTANCE' }, + ], + findAllWithCriteria: jest.fn().mockReturnValue([ + { id: 'rect-1', type: 'RECTANGLE' }, + { id: 'instance-1', type: 'INSTANCE' }, + ]), + }, + ] as any; + + const result = findAll(mockNodes, true); + // Should include COMPONENT and RECTANGLE, but NOT INSTANCE + expect(result).toHaveLength(2); + expect(result.map(n => n.id)).toContain('component-1'); + expect(result.map(n => n.id)).toContain('rect-1'); + expect(result.map(n => n.id)).not.toContain('instance-1'); + }); + + it('should exclude instances when recursing from a COMPONENT_SET', () => { + const mockNodes = [ + { + id: 'component-set-1', + type: 'COMPONENT_SET', + children: [ + { id: 'rect-1', type: 'RECTANGLE' }, + { id: 'instance-1', type: 'INSTANCE' }, + ], + findAllWithCriteria: jest.fn().mockReturnValue([ + { id: 'rect-1', type: 'RECTANGLE' }, + { id: 'instance-1', type: 'INSTANCE' }, + ]), + }, + ] as any; + + const result = findAll(mockNodes, true); + // Should include COMPONENT_SET and RECTANGLE, but NOT INSTANCE + expect(result).toHaveLength(2); + expect(result.map(n => n.id)).toContain('component-set-1'); + expect(result.map(n => n.id)).toContain('rect-1'); + expect(result.map(n => n.id)).not.toContain('instance-1'); + }); + + it('should NOT exclude instances when recursing from a FRAME', () => { + const mockNodes = [ + { + id: 'frame-1', + type: 'FRAME', + children: [ + { id: 'rect-1', type: 'RECTANGLE' }, + { id: 'instance-1', type: 'INSTANCE' }, + ], + findAllWithCriteria: jest.fn().mockReturnValue([ + { id: 'rect-1', type: 'RECTANGLE' }, + { id: 'instance-1', type: 'INSTANCE' }, + ]), + }, + ] as any; + + const result = findAll(mockNodes, true); + // Should include FRAME, RECTANGLE, AND INSTANCE + expect(result).toHaveLength(3); + expect(result.map(n => n.id)).toContain('frame-1'); + expect(result.map(n => n.id)).toContain('rect-1'); + expect(result.map(n => n.id)).toContain('instance-1'); + }); +}); diff --git a/packages/tokens-studio-for-figma/src/utils/findAll.ts b/packages/tokens-studio-for-figma/src/utils/findAll.ts index 013e40f9a..d42e13010 100644 --- a/packages/tokens-studio-for-figma/src/utils/findAll.ts +++ b/packages/tokens-studio-for-figma/src/utils/findAll.ts @@ -11,12 +11,16 @@ export function findAll(nodes: readonly BaseNode[], includeSelf = false, nodesWi }; nodes.forEach((node) => { if ('children' in node) { - allNodes = allNodes.concat( - node.findAllWithCriteria({ - types: ValidNodeTypes, - ...pluginDataOptions, - }), - ); + const childNodes = node.findAllWithCriteria({ + types: ValidNodeTypes, + ...pluginDataOptions, + }); + + const filteredChildren = node.type === 'COMPONENT' || node.type === 'COMPONENT_SET' + ? childNodes.filter((child) => child.type !== 'INSTANCE') + : childNodes; + + allNodes = allNodes.concat(filteredChildren); } }); return allNodes;