Skip to content

Commit 1e214f8

Browse files
authored
Merge pull request #45 from dev-five-git/fix-component-issue
Fix component issue
2 parents 420f3b7 + 5ee6fd2 commit 1e214f8

File tree

7 files changed

+200
-115
lines changed

7 files changed

+200
-115
lines changed

src/code-impl.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -418,10 +418,25 @@ export function registerCodegen(ctx: typeof figma) {
418418
}
419419
}
420420

421-
const allComponentsCodes = [
422-
...componentsResponsiveCodes,
423-
...responsiveComponentsCodes,
424-
]
421+
// Merge component codes: responsive/variant versions override simple ones.
422+
const responsiveOverrides = new Map<
423+
string,
424+
readonly [string, string]
425+
>()
426+
for (const entry of componentsResponsiveCodes)
427+
responsiveOverrides.set(entry[0], entry)
428+
for (const entry of responsiveComponentsCodes)
429+
responsiveOverrides.set(entry[0], entry)
430+
431+
const mergedComponentsCodes: ReadonlyArray<
432+
readonly [string, string]
433+
> =
434+
componentsCodes.length > 0 && responsiveOverrides.size > 0
435+
? componentsCodes.map(
436+
([name, code]) =>
437+
responsiveOverrides.get(name) ?? ([name, code] as const),
438+
)
439+
: componentsCodes
425440

426441
// For INSTANCE nodes, include the referenced component definition(s)
427442
// alongside Usage so developers see both how to use AND the implementation.
@@ -430,14 +445,16 @@ export function registerCodegen(ctx: typeof figma) {
430445
language: 'TYPESCRIPT' | 'BASH'
431446
code: string
432447
}[] = []
433-
if (node.type === 'INSTANCE' && componentsCodes.length > 0) {
434-
const importStatement = generateImportStatements(componentsCodes)
435-
const combinedCode = componentsCodes
448+
if (node.type === 'INSTANCE' && mergedComponentsCodes.length > 0) {
449+
const importStatement = generateImportStatements(
450+
mergedComponentsCodes,
451+
)
452+
const combinedCode = mergedComponentsCodes
436453
.map(([, code]) => code)
437454
.join('\n\n')
438455
const label =
439-
componentsCodes.length === 1
440-
? componentsCodes[0][0]
456+
mergedComponentsCodes.length === 1
457+
? mergedComponentsCodes[0][0]
441458
: `${node.name} - Components`
442459
componentDefinitionResults.push(
443460
{
@@ -448,16 +465,31 @@ export function registerCodegen(ctx: typeof figma) {
448465
{
449466
title: `${label} - CLI (Bash)`,
450467
language: 'BASH',
451-
code: generateBashCLI(componentsCodes),
468+
code: generateBashCLI(mergedComponentsCodes),
452469
},
453470
{
454471
title: `${label} - CLI (PowerShell)`,
455472
language: 'BASH',
456-
code: generatePowerShellCLI(componentsCodes),
473+
code: generatePowerShellCLI(mergedComponentsCodes),
457474
},
458475
)
459476
}
460477

478+
// Collect remaining responsive codes NOT already merged into component definitions.
479+
// Only filter for INSTANCE nodes — other node types don't produce componentDefinitionResults.
480+
const mergedNames =
481+
node.type === 'INSTANCE'
482+
? new Set(mergedComponentsCodes.map(([name]) => name))
483+
: new Set<string>()
484+
const allComponentsCodes = [
485+
...componentsResponsiveCodes.filter(
486+
([name]) => !mergedNames.has(name),
487+
),
488+
...responsiveComponentsCodes.filter(
489+
([name]) => !mergedNames.has(name),
490+
),
491+
]
492+
461493
// For COMPONENT nodes, show both the single-variant code AND Usage.
462494
// For COMPONENT_SET and INSTANCE, show only Usage.
463495
// For all other types, show the main code.

src/codegen/Codegen.ts

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,6 @@ export class Codegen {
282282
private buildTreeCache: Map<string, Promise<NodeTree>> = new Map()
283283
// Collect fire-and-forget addComponentTree promises so we can await them
284284
// before rendering component codes (decouples INSTANCE buildTree from addComponentTree)
285-
private pendingComponentTrees: Promise<void>[] = []
286285

287286
constructor(private node: SceneNode) {
288287
this.node = node
@@ -356,10 +355,12 @@ export class Codegen {
356355
this.tree = tree
357356
}
358357

359-
// Await all fire-and-forget addComponentTree calls before rendering
360-
if (this.pendingComponentTrees.length > 0) {
361-
await Promise.all(this.pendingComponentTrees)
362-
this.pendingComponentTrees = []
358+
// Drain all addComponentTree promises, including nested ones added during execution.
359+
// Uses addComponentTreePromises Map which stably tracks every fired promise.
360+
let _prevSize = 0
361+
while (this.addComponentTreePromises.size > _prevSize) {
362+
_prevSize = this.addComponentTreePromises.size
363+
await Promise.all(this.addComponentTreePromises.values())
363364
}
364365

365366
// Sync componentTrees to components
@@ -409,13 +410,6 @@ export class Codegen {
409410
globalBuildTreeCache.set(cacheKey, promise)
410411
}
411412
const result = await promise
412-
// When called as the root-level buildTree (node === this.node),
413-
// drain any fire-and-forget addComponentTree promises so that
414-
// getComponentTrees() is populated before the caller inspects it.
415-
if (node === this.node && this.pendingComponentTrees.length > 0) {
416-
await Promise.all(this.pendingComponentTrees)
417-
this.pendingComponentTrees = []
418-
}
419413
return result
420414
}
421415

@@ -431,10 +425,9 @@ export class Codegen {
431425
node === this.node.defaultVariant) ||
432426
this.node.type === 'COMPONENT')
433427
) {
434-
this.pendingComponentTrees.push(
435-
this.addComponentTree(
436-
node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
437-
),
428+
// Fire-and-forget — errors collected via addComponentTreePromises in run().
429+
this.addComponentTree(
430+
node.type === 'COMPONENT_SET' ? node.defaultVariant : node,
438431
)
439432
}
440433

@@ -462,7 +455,7 @@ export class Codegen {
462455
// Fire addComponentTree without awaiting — it runs in the background.
463456
// All pending promises are collected and awaited in run() before rendering.
464457
if (mainComponent) {
465-
this.pendingComponentTrees.push(this.addComponentTree(mainComponent))
458+
this.addComponentTree(mainComponent)
466459
}
467460

468461
const componentName = getComponentName(mainComponent || node)
@@ -570,7 +563,10 @@ export class Codegen {
570563
if (!globalAssetNodes.has(assetKey)) {
571564
globalAssetNodes.set(assetKey, { node, type: assetNode })
572565
}
573-
const props = await getProps(node)
566+
const baseProps = await getProps(node)
567+
// Clone to avoid mutating the shared getProps cache — subsequent
568+
// codegen runs (e.g. ResponsiveCodegen) reuse the cached reference.
569+
const props: Record<string, unknown> = { ...baseProps }
574570
props.src = `/${assetNode === 'svg' ? 'icons' : 'images'}/${node.name}.${assetNode}`
575571
if (assetNode === 'svg') {
576572
const maskColor = await checkSameColor(node)
@@ -679,10 +675,11 @@ export class Codegen {
679675
async getTree(): Promise<NodeTree> {
680676
if (!this.tree) {
681677
this.tree = await this.buildTree(this.node)
682-
// Await any fire-and-forget addComponentTree calls launched during buildTree
683-
if (this.pendingComponentTrees.length > 0) {
684-
await Promise.all(this.pendingComponentTrees)
685-
this.pendingComponentTrees = []
678+
// Drain all addComponentTree promises (including nested ones)
679+
let _prevSize = 0
680+
while (this.addComponentTreePromises.size > _prevSize) {
681+
_prevSize = this.addComponentTreePromises.size
682+
await Promise.all(this.addComponentTreePromises.values())
686683
}
687684
}
688685
return this.tree
@@ -702,15 +699,19 @@ export class Codegen {
702699
// when multiple INSTANCE nodes reference the same component
703700
private addComponentTreePromises: Map<string, Promise<void>> = new Map()
704701

705-
private async addComponentTree(node: ComponentNode): Promise<void> {
702+
private addComponentTree(node: ComponentNode): Promise<void> {
706703
const nodeId = node.id || node.name
707-
if (this.componentTrees.has(nodeId)) return
704+
if (this.componentTrees.has(nodeId)) return Promise.resolve()
708705

709-
// If already in-flight, await the same promise
706+
// If already in-flight, return the same promise
710707
const inflight = this.addComponentTreePromises.get(nodeId)
711708
if (inflight) return inflight
712709

710+
// Store the raw promise (may reject) for drain in run().
711+
// Attach a no-op .catch so fire-and-forget callers don't
712+
// trigger unhandled rejection warnings.
713713
const promise = this.doAddComponentTree(node, nodeId)
714+
promise.catch(() => {})
714715
this.addComponentTreePromises.set(nodeId, promise)
715716
return promise
716717
}

src/commands/devup/__tests__/import-devup.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
import { describe, expect, mock, spyOn, test } from 'bun:test'
1+
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
22
import * as uploadFileModule from '../../../utils/upload-file'
33
import { importDevup } from '../import-devup'
44
import * as uploadXlsxModule from '../utils/upload-devup-xlsx'
55

66
describe('import-devup (standalone file)', () => {
7+
const spies: ReturnType<typeof spyOn>[] = []
8+
afterEach(() => {
9+
for (const s of spies) s.mockRestore()
10+
spies.length = 0
11+
;(globalThis as { figma?: unknown }).figma = undefined
12+
})
713
test('returns early when theme is missing', async () => {
814
const uploadFile = mock(() => Promise.resolve('{}'))
9-
spyOn(uploadFileModule, 'uploadFile').mockImplementation(uploadFile)
15+
spies.push(
16+
spyOn(uploadFileModule, 'uploadFile').mockImplementation(uploadFile),
17+
)
1018
await importDevup('json')
1119
expect(uploadFile).toHaveBeenCalledWith('.json')
1220
})
@@ -30,7 +38,9 @@ describe('import-devup (standalone file)', () => {
3038
},
3139
}),
3240
)
33-
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx)
41+
spies.push(
42+
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx),
43+
)
3444

3545
const setValueForMode = mock(() => {})
3646
const createVariable = mock(
@@ -89,7 +99,9 @@ describe('import-devup (standalone file)', () => {
8999
},
90100
}),
91101
)
92-
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx)
102+
spies.push(
103+
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx),
104+
)
93105

94106
const removeDevupVariable = mock(() => {})
95107
const removeOtherVariable = mock(() => {})
@@ -167,7 +179,9 @@ describe('import-devup (standalone file)', () => {
167179
},
168180
}),
169181
)
170-
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx)
182+
spies.push(
183+
spyOn(uploadXlsxModule, 'uploadDevupXlsx').mockImplementation(uploadXlsx),
184+
)
171185

172186
const createTextStyle = mock(
173187
() =>

src/commands/devup/utils/__tests__/download-devup-xlsx.test.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
1+
import { describe, expect, mock, test } from 'bun:test'
22
import { downloadDevupXlsx } from '../download-devup-xlsx'
33

44
describe('downloadDevupXlsx', () => {
5-
let showUIMock: ReturnType<typeof mock>
6-
let postMessageMock: ReturnType<typeof mock>
7-
let onmessageHandler: ((message: unknown) => void) | null = null
8-
9-
beforeEach(() => {
10-
showUIMock = mock(() => {})
11-
postMessageMock = mock(() => {})
12-
onmessageHandler = null
5+
function createMockFigma() {
6+
const showUIMock = mock(() => {})
7+
const postMessageMock = mock(() => {})
8+
let onmessageHandler: ((message: unknown) => void) | null = null
139

1410
const uiObj: {
1511
onmessage?: (message: unknown) => void
@@ -26,18 +22,26 @@ describe('downloadDevupXlsx', () => {
2622

2723
uiObj.postMessage = postMessageMock
2824

29-
;(globalThis as { figma?: unknown }).figma = {
25+
const ctx = {
3026
showUI: showUIMock,
3127
ui: uiObj,
3228
} as unknown as typeof figma
33-
})
3429

35-
afterEach(() => {
36-
;(globalThis as { figma?: unknown }).figma = undefined
37-
})
30+
return {
31+
ctx,
32+
showUIMock,
33+
postMessageMock,
34+
getHandler: () => onmessageHandler,
35+
}
36+
}
3837

3938
test('should call showUI with correct HTML string and visible false', () => {
40-
downloadDevupXlsx('test.xlsx', '{"theme":{"colors":{},"typography":{}}}')
39+
const { ctx, showUIMock } = createMockFigma()
40+
downloadDevupXlsx(
41+
'test.xlsx',
42+
'{"theme":{"colors":{},"typography":{}}}',
43+
ctx,
44+
)
4145
expect(showUIMock).toHaveBeenCalledWith(
4246
expect.stringContaining('xlsx-0.20.3'),
4347
{ visible: false },
@@ -49,8 +53,13 @@ describe('downloadDevupXlsx', () => {
4953
})
5054

5155
test('should set onmessage handler and post message', () => {
52-
downloadDevupXlsx('test.xlsx', '{"theme":{"colors":{},"typography":{}}}')
53-
expect(onmessageHandler).not.toBeNull()
56+
const { ctx, getHandler, postMessageMock } = createMockFigma()
57+
downloadDevupXlsx(
58+
'test.xlsx',
59+
'{"theme":{"colors":{},"typography":{}}}',
60+
ctx,
61+
)
62+
expect(getHandler()).not.toBeNull()
5463
expect(postMessageMock).toHaveBeenCalledWith({
5564
type: 'download',
5665
fileName: 'test.xlsx',
@@ -59,26 +68,31 @@ describe('downloadDevupXlsx', () => {
5968
})
6069

6170
test('should return a promise that resolves when onmessage is called', async () => {
71+
const { ctx, getHandler, postMessageMock } = createMockFigma()
6272
const promise = downloadDevupXlsx(
6373
'test.xlsx',
6474
'{"theme":{"colors":{},"typography":{}}}',
75+
ctx,
6576
)
6677

6778
// Simulate message from UI
68-
if (onmessageHandler) {
69-
onmessageHandler(undefined)
79+
const handler = getHandler()
80+
if (handler) {
81+
handler(undefined)
7082
}
7183

7284
await promise
7385
expect(postMessageMock).toHaveBeenCalled()
7486
})
7587

7688
test('should handle different file names and data', () => {
89+
const { ctx, postMessageMock } = createMockFigma()
7790
downloadDevupXlsx(
7891
'devup.xlsx',
7992
JSON.stringify({
8093
theme: { colors: { light: { primary: '#000' } }, typography: {} },
8194
}),
95+
ctx,
8296
)
8397
expect(postMessageMock).toHaveBeenCalledWith({
8498
type: 'download',

0 commit comments

Comments
 (0)