diff --git a/src/code-impl.ts b/src/code-impl.ts index b43fe40..4a7956d 100644 --- a/src/code-impl.ts +++ b/src/code-impl.ts @@ -418,10 +418,25 @@ export function registerCodegen(ctx: typeof figma) { } } - const allComponentsCodes = [ - ...componentsResponsiveCodes, - ...responsiveComponentsCodes, - ] + // Merge component codes: responsive/variant versions override simple ones. + const responsiveOverrides = new Map< + string, + readonly [string, string] + >() + for (const entry of componentsResponsiveCodes) + responsiveOverrides.set(entry[0], entry) + for (const entry of responsiveComponentsCodes) + responsiveOverrides.set(entry[0], entry) + + const mergedComponentsCodes: ReadonlyArray< + readonly [string, string] + > = + componentsCodes.length > 0 && responsiveOverrides.size > 0 + ? componentsCodes.map( + ([name, code]) => + responsiveOverrides.get(name) ?? ([name, code] as const), + ) + : componentsCodes // For INSTANCE nodes, include the referenced component definition(s) // alongside Usage so developers see both how to use AND the implementation. @@ -430,14 +445,16 @@ export function registerCodegen(ctx: typeof figma) { language: 'TYPESCRIPT' | 'BASH' code: string }[] = [] - if (node.type === 'INSTANCE' && componentsCodes.length > 0) { - const importStatement = generateImportStatements(componentsCodes) - const combinedCode = componentsCodes + if (node.type === 'INSTANCE' && mergedComponentsCodes.length > 0) { + const importStatement = generateImportStatements( + mergedComponentsCodes, + ) + const combinedCode = mergedComponentsCodes .map(([, code]) => code) .join('\n\n') const label = - componentsCodes.length === 1 - ? componentsCodes[0][0] + mergedComponentsCodes.length === 1 + ? mergedComponentsCodes[0][0] : `${node.name} - Components` componentDefinitionResults.push( { @@ -448,16 +465,31 @@ export function registerCodegen(ctx: typeof figma) { { title: `${label} - CLI (Bash)`, language: 'BASH', - code: generateBashCLI(componentsCodes), + code: generateBashCLI(mergedComponentsCodes), }, { title: `${label} - CLI (PowerShell)`, language: 'BASH', - code: generatePowerShellCLI(componentsCodes), + code: generatePowerShellCLI(mergedComponentsCodes), }, ) } + // Collect remaining responsive codes NOT already merged into component definitions. + // Only filter for INSTANCE nodes — other node types don't produce componentDefinitionResults. + const mergedNames = + node.type === 'INSTANCE' + ? new Set(mergedComponentsCodes.map(([name]) => name)) + : new Set() + const allComponentsCodes = [ + ...componentsResponsiveCodes.filter( + ([name]) => !mergedNames.has(name), + ), + ...responsiveComponentsCodes.filter( + ([name]) => !mergedNames.has(name), + ), + ] + // For COMPONENT nodes, show both the single-variant code AND Usage. // For COMPONENT_SET and INSTANCE, show only Usage. // For all other types, show the main code. diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts index 06c1d6a..73dd944 100644 --- a/src/codegen/Codegen.ts +++ b/src/codegen/Codegen.ts @@ -282,7 +282,6 @@ export class Codegen { private buildTreeCache: Map> = new Map() // Collect fire-and-forget addComponentTree promises so we can await them // before rendering component codes (decouples INSTANCE buildTree from addComponentTree) - private pendingComponentTrees: Promise[] = [] constructor(private node: SceneNode) { this.node = node @@ -356,10 +355,12 @@ export class Codegen { this.tree = tree } - // Await all fire-and-forget addComponentTree calls before rendering - if (this.pendingComponentTrees.length > 0) { - await Promise.all(this.pendingComponentTrees) - this.pendingComponentTrees = [] + // Drain all addComponentTree promises, including nested ones added during execution. + // Uses addComponentTreePromises Map which stably tracks every fired promise. + let _prevSize = 0 + while (this.addComponentTreePromises.size > _prevSize) { + _prevSize = this.addComponentTreePromises.size + await Promise.all(this.addComponentTreePromises.values()) } // Sync componentTrees to components @@ -409,13 +410,6 @@ export class Codegen { globalBuildTreeCache.set(cacheKey, promise) } const result = await promise - // When called as the root-level buildTree (node === this.node), - // drain any fire-and-forget addComponentTree promises so that - // getComponentTrees() is populated before the caller inspects it. - if (node === this.node && this.pendingComponentTrees.length > 0) { - await Promise.all(this.pendingComponentTrees) - this.pendingComponentTrees = [] - } return result } @@ -431,10 +425,9 @@ export class Codegen { node === this.node.defaultVariant) || this.node.type === 'COMPONENT') ) { - this.pendingComponentTrees.push( - this.addComponentTree( - node.type === 'COMPONENT_SET' ? node.defaultVariant : node, - ), + // Fire-and-forget — errors collected via addComponentTreePromises in run(). + this.addComponentTree( + node.type === 'COMPONENT_SET' ? node.defaultVariant : node, ) } @@ -462,7 +455,7 @@ export class Codegen { // Fire addComponentTree without awaiting — it runs in the background. // All pending promises are collected and awaited in run() before rendering. if (mainComponent) { - this.pendingComponentTrees.push(this.addComponentTree(mainComponent)) + this.addComponentTree(mainComponent) } const componentName = getComponentName(mainComponent || node) @@ -570,7 +563,10 @@ export class Codegen { if (!globalAssetNodes.has(assetKey)) { globalAssetNodes.set(assetKey, { node, type: assetNode }) } - const props = await getProps(node) + const baseProps = await getProps(node) + // Clone to avoid mutating the shared getProps cache — subsequent + // codegen runs (e.g. ResponsiveCodegen) reuse the cached reference. + const props: Record = { ...baseProps } props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}` if (assetNode === 'svg') { const maskColor = await checkSameColor(node) @@ -679,10 +675,11 @@ export class Codegen { async getTree(): Promise { if (!this.tree) { this.tree = await this.buildTree(this.node) - // Await any fire-and-forget addComponentTree calls launched during buildTree - if (this.pendingComponentTrees.length > 0) { - await Promise.all(this.pendingComponentTrees) - this.pendingComponentTrees = [] + // Drain all addComponentTree promises (including nested ones) + let _prevSize = 0 + while (this.addComponentTreePromises.size > _prevSize) { + _prevSize = this.addComponentTreePromises.size + await Promise.all(this.addComponentTreePromises.values()) } } return this.tree @@ -702,15 +699,19 @@ export class Codegen { // when multiple INSTANCE nodes reference the same component private addComponentTreePromises: Map> = new Map() - private async addComponentTree(node: ComponentNode): Promise { + private addComponentTree(node: ComponentNode): Promise { const nodeId = node.id || node.name - if (this.componentTrees.has(nodeId)) return + if (this.componentTrees.has(nodeId)) return Promise.resolve() - // If already in-flight, await the same promise + // If already in-flight, return the same promise const inflight = this.addComponentTreePromises.get(nodeId) if (inflight) return inflight + // Store the raw promise (may reject) for drain in run(). + // Attach a no-op .catch so fire-and-forget callers don't + // trigger unhandled rejection warnings. const promise = this.doAddComponentTree(node, nodeId) + promise.catch(() => {}) this.addComponentTreePromises.set(nodeId, promise) return promise } diff --git a/src/commands/devup/__tests__/import-devup.test.ts b/src/commands/devup/__tests__/import-devup.test.ts index 84a3ca5..065ae94 100644 --- a/src/commands/devup/__tests__/import-devup.test.ts +++ b/src/commands/devup/__tests__/import-devup.test.ts @@ -1,12 +1,20 @@ -import { describe, expect, mock, spyOn, test } from 'bun:test' +import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' import * as uploadFileModule from '../../../utils/upload-file' import { importDevup } from '../import-devup' import * as uploadXlsxModule from '../utils/upload-devup-xlsx' describe('import-devup (standalone file)', () => { + const spies: ReturnType[] = [] + afterEach(() => { + for (const s of spies) s.mockRestore() + spies.length = 0 + ;(globalThis as { figma?: unknown }).figma = undefined + }) test('returns early when theme is missing', async () => { const uploadFile = mock(() => Promise.resolve('{}')) - spyOn(uploadFileModule, 'uploadFile').mockImplementation(uploadFile) + spies.push( + spyOn(uploadFileModule, 'uploadFile').mockImplementation(uploadFile), + ) await importDevup('json') expect(uploadFile).toHaveBeenCalledWith('.json') }) @@ -30,7 +38,9 @@ describe('import-devup (standalone file)', () => { }, }), ) - spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx) + spies.push( + spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx), + ) const setValueForMode = mock(() => {}) const createVariable = mock( @@ -89,7 +99,9 @@ describe('import-devup (standalone file)', () => { }, }), ) - spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx) + spies.push( + spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx), + ) const removeDevupVariable = mock(() => {}) const removeOtherVariable = mock(() => {}) @@ -167,7 +179,9 @@ describe('import-devup (standalone file)', () => { }, }), ) - spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx) + spies.push( + spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx), + ) const createTextStyle = mock( () => diff --git a/src/commands/devup/utils/__tests__/download-devup-xlsx.test.ts b/src/commands/devup/utils/__tests__/download-devup-xlsx.test.ts index 74ba2ad..d6f04cb 100644 --- a/src/commands/devup/utils/__tests__/download-devup-xlsx.test.ts +++ b/src/commands/devup/utils/__tests__/download-devup-xlsx.test.ts @@ -1,15 +1,11 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { describe, expect, mock, test } from 'bun:test' import { downloadDevupXlsx } from '../download-devup-xlsx' describe('downloadDevupXlsx', () => { - let showUIMock: ReturnType - let postMessageMock: ReturnType - let onmessageHandler: ((message: unknown) => void) | null = null - - beforeEach(() => { - showUIMock = mock(() => {}) - postMessageMock = mock(() => {}) - onmessageHandler = null + function createMockFigma() { + const showUIMock = mock(() => {}) + const postMessageMock = mock(() => {}) + let onmessageHandler: ((message: unknown) => void) | null = null const uiObj: { onmessage?: (message: unknown) => void @@ -26,18 +22,26 @@ describe('downloadDevupXlsx', () => { uiObj.postMessage = postMessageMock - ;(globalThis as { figma?: unknown }).figma = { + const ctx = { showUI: showUIMock, ui: uiObj, } as unknown as typeof figma - }) - afterEach(() => { - ;(globalThis as { figma?: unknown }).figma = undefined - }) + return { + ctx, + showUIMock, + postMessageMock, + getHandler: () => onmessageHandler, + } + } test('should call showUI with correct HTML string and visible false', () => { - downloadDevupXlsx('test.xlsx', '{"theme":{"colors":{},"typography":{}}}') + const { ctx, showUIMock } = createMockFigma() + downloadDevupXlsx( + 'test.xlsx', + '{"theme":{"colors":{},"typography":{}}}', + ctx, + ) expect(showUIMock).toHaveBeenCalledWith( expect.stringContaining('xlsx-0.20.3'), { visible: false }, @@ -49,8 +53,13 @@ describe('downloadDevupXlsx', () => { }) test('should set onmessage handler and post message', () => { - downloadDevupXlsx('test.xlsx', '{"theme":{"colors":{},"typography":{}}}') - expect(onmessageHandler).not.toBeNull() + const { ctx, getHandler, postMessageMock } = createMockFigma() + downloadDevupXlsx( + 'test.xlsx', + '{"theme":{"colors":{},"typography":{}}}', + ctx, + ) + expect(getHandler()).not.toBeNull() expect(postMessageMock).toHaveBeenCalledWith({ type: 'download', fileName: 'test.xlsx', @@ -59,14 +68,17 @@ describe('downloadDevupXlsx', () => { }) test('should return a promise that resolves when onmessage is called', async () => { + const { ctx, getHandler, postMessageMock } = createMockFigma() const promise = downloadDevupXlsx( 'test.xlsx', '{"theme":{"colors":{},"typography":{}}}', + ctx, ) // Simulate message from UI - if (onmessageHandler) { - onmessageHandler(undefined) + const handler = getHandler() + if (handler) { + handler(undefined) } await promise @@ -74,11 +86,13 @@ describe('downloadDevupXlsx', () => { }) test('should handle different file names and data', () => { + const { ctx, postMessageMock } = createMockFigma() downloadDevupXlsx( 'devup.xlsx', JSON.stringify({ theme: { colors: { light: { primary: '#000' } }, typography: {} }, }), + ctx, ) expect(postMessageMock).toHaveBeenCalledWith({ type: 'download', diff --git a/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts b/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts index d785967..36020be 100644 --- a/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts +++ b/src/commands/devup/utils/__tests__/upload-devup-xlsx.test.ts @@ -1,16 +1,12 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { describe, expect, mock, test } from 'bun:test' import type { Devup } from '../../types' import { uploadDevupXlsx } from '../upload-devup-xlsx' describe('uploadDevupXlsx', () => { - let showUIMock: ReturnType - let closeMock: ReturnType - let onmessageHandler: ((message: string) => void) | null = null - - beforeEach(() => { - showUIMock = mock(() => {}) - closeMock = mock(() => {}) - onmessageHandler = null + function setupMockFigma() { + const showUIMock = mock(() => {}) + const closeMock = mock(() => {}) + let onmessageHandler: ((message: string) => void) | null = null const uiObj: { onmessage?: (message: string) => void @@ -27,63 +23,85 @@ describe('uploadDevupXlsx', () => { uiObj.close = closeMock - ;(globalThis as { figma?: unknown }).figma = { + const ctx = { showUI: showUIMock, ui: uiObj, } as unknown as typeof figma - }) - afterEach(() => { + // Also set globalThis.figma as fallback — guards against Bun's parallel + // test runner potentially resolving a cached module without the ctx param. + ;(globalThis as { figma?: unknown }).figma = ctx + + return { ctx, showUIMock, closeMock, getHandler: () => onmessageHandler } + } + + function teardown() { ;(globalThis as { figma?: unknown }).figma = undefined - }) + } test('should call showUI with correct HTML string', () => { - uploadDevupXlsx() - expect(showUIMock).toHaveBeenCalledWith( - expect.stringContaining('accept=".xlsx"'), - ) - expect(showUIMock).toHaveBeenCalledWith( - expect.stringContaining('xlsx-0.20.3'), - ) + const { ctx, showUIMock } = setupMockFigma() + try { + uploadDevupXlsx(ctx) + expect(showUIMock).toHaveBeenCalledWith( + expect.stringContaining('accept=".xlsx"'), + ) + expect(showUIMock).toHaveBeenCalledWith( + expect.stringContaining('xlsx-0.20.3'), + ) + } finally { + teardown() + } }) test('should resolve with parsed JSON when message is received', async () => { - const testData = { theme: { colors: {}, typography: {} } } - const promise = uploadDevupXlsx() + const { ctx, closeMock, getHandler } = setupMockFigma() + try { + const testData = { theme: { colors: {}, typography: {} } } + const promise = uploadDevupXlsx(ctx) - // Simulate message from UI - if (onmessageHandler) { - onmessageHandler(JSON.stringify(testData)) - } + const handler = getHandler() + if (handler) { + handler(JSON.stringify(testData)) + } - const result = await promise - expect(closeMock).toHaveBeenCalled() - expect(result).toEqual(testData) + const result = await promise + expect(closeMock).toHaveBeenCalled() + expect(result).toEqual(testData) + } finally { + teardown() + } }) test('should handle message with colors and typography', async () => { - const testData = { - theme: { - colors: { - light: { - primary: '#000000', + const { ctx, getHandler } = setupMockFigma() + try { + const testData = { + theme: { + colors: { + light: { + primary: '#000000', + }, }, - }, - typography: { - heading: { - fontFamily: 'Arial', - fontSize: 24, + typography: { + heading: { + fontFamily: 'Arial', + fontSize: 24, + }, }, }, - }, - } - const promise = uploadDevupXlsx() + } + const promise = uploadDevupXlsx(ctx) - if (onmessageHandler) { - onmessageHandler(JSON.stringify(testData)) - } + const handler = getHandler() + if (handler) { + handler(JSON.stringify(testData)) + } - const result = await promise - expect(result).toEqual(testData as unknown as Devup) + const result = await promise + expect(result).toEqual(testData as unknown as Devup) + } finally { + teardown() + } }) }) diff --git a/src/commands/devup/utils/download-devup-xlsx.ts b/src/commands/devup/utils/download-devup-xlsx.ts index 8e7848f..d9d51e6 100644 --- a/src/commands/devup/utils/download-devup-xlsx.ts +++ b/src/commands/devup/utils/download-devup-xlsx.ts @@ -3,16 +3,20 @@ * @param fileName * @param data */ -export async function downloadDevupXlsx(fileName: string, data: string) { - figma.showUI(downloadFileUi(), { +export async function downloadDevupXlsx( + fileName: string, + data: string, + ctx: typeof figma = figma, +) { + ctx.showUI(downloadFileUi(), { visible: false, }) const pro = new Promise((resolve) => { - figma.ui.onmessage = resolve + ctx.ui.onmessage = resolve }) - figma.ui.postMessage({ + ctx.ui.postMessage({ type: 'download', fileName, data, diff --git a/src/commands/devup/utils/upload-devup-xlsx.ts b/src/commands/devup/utils/upload-devup-xlsx.ts index 5896ee8..cc7893e 100644 --- a/src/commands/devup/utils/upload-devup-xlsx.ts +++ b/src/commands/devup/utils/upload-devup-xlsx.ts @@ -5,11 +5,13 @@ import type { Devup } from '../types' * @param fileName * @param data */ -export async function uploadDevupXlsx(): Promise { - figma.showUI(uploadFileUi('.xlsx')) +export async function uploadDevupXlsx( + ctx: typeof figma = figma, +): Promise { + ctx.showUI(uploadFileUi('.xlsx')) return new Promise((resolve) => { - figma.ui.onmessage = (message) => { - figma.ui.close() + ctx.ui.onmessage = (message) => { + ctx.ui.close() resolve(JSON.parse(message)) } })