Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 32 additions & 32 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"license": "",
"devDependencies": {
"@figma/plugin-typings": "^1.123",
"@rspack/cli": "^1.7.8",
"@rspack/core": "^1.7.8",
"@rspack/cli": "^1.7.9",
"@rspack/core": "^1.7.9",

"husky": "^9.1",
"typescript": "^5.9",
Expand Down
6 changes: 3 additions & 3 deletions src/code-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './codegen/props/selector'
import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen'
import { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props'
import { getComponentPropertyDefinitions } from './codegen/utils/get-component-property-definitions'
import { nodeProxyTracker } from './codegen/utils/node-proxy'
import { perfEnd, perfReport, perfReset, perfStart } from './codegen/utils/perf'
import { resetVariableCache } from './codegen/utils/variable-cache'
Expand Down Expand Up @@ -111,7 +112,7 @@ export function generateComponentUsage(node: SceneNode): string | null {
(node as ComponentNode).parent?.type === 'COMPONENT_SET'
? ((node as ComponentNode).parent as ComponentSetNode)
: null
const defs = parentSet?.componentPropertyDefinitions
const defs = getComponentPropertyDefinitions(parentSet)
let textEntry: { key: string; value: string } | null = null
let textCount = 0
if (defs) {
Expand Down Expand Up @@ -162,8 +163,7 @@ export function generateComponentUsage(node: SceneNode): string | null {
}

if (node.type === 'COMPONENT_SET') {
const defs = (node as ComponentSetNode).componentPropertyDefinitions
if (!defs) return `<${componentName} />`
const defs = getComponentPropertyDefinitions(node as ComponentSetNode)

const entries: { key: string; value: string; type: string }[] = []
let textEntry: { key: string; value: string } | null = null
Expand Down
13 changes: 7 additions & 6 deletions src/codegen/Codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { ComponentTree, NodeTree } from './types'
import { checkAssetNode } from './utils/check-asset-node'
import { checkSameColor } from './utils/check-same-color'
import { extractInstanceVariantProps } from './utils/extract-instance-variant-props'
import { getComponentPropertyDefinitions } from './utils/get-component-property-definitions'
import {
getDevupComponentByNode,
getDevupComponentByProps,
Expand Down Expand Up @@ -639,10 +640,9 @@ export class Codegen {

// Collect INSTANCE_SWAP and BOOLEAN property definitions for slot/condition detection.
const parentSet = node.parent?.type === 'COMPONENT_SET' ? node.parent : null
const propDefs =
parentSet?.componentPropertyDefinitions ||
node.componentPropertyDefinitions ||
{}
const propDefs = parentSet
? getComponentPropertyDefinitions(parentSet)
: getComponentPropertyDefinitions(node)
const instanceSwapSlots = new Map<string, string>()
const booleanSlots = new Map<string, string>()
const textSlots = new Map<string, string>()
Expand Down Expand Up @@ -758,8 +758,9 @@ export class Codegen {
*/
hasViewportVariant(): boolean {
if (this.node.type !== 'COMPONENT_SET') return false
for (const key in (this.node as ComponentSetNode)
.componentPropertyDefinitions) {
for (const key in getComponentPropertyDefinitions(
this.node as ComponentSetNode,
)) {
if (key.toLowerCase() === 'viewport') return true
}
return false
Expand Down
16 changes: 10 additions & 6 deletions src/codegen/props/selector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fmtPct } from '../utils/fmtPct'
import { getComponentPropertyDefinitions } from '../utils/get-component-property-definitions'
import { perfEnd, perfStart } from '../utils/perf'
import { getProps } from '.'

Expand Down Expand Up @@ -141,7 +142,8 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{
variants: Record<string, string>
variantComments: Record<string, string>
}> {
const hasEffect = !!node.componentPropertyDefinitions.effect
const propDefs = getComponentPropertyDefinitions(node)
const hasEffect = !!propDefs.effect
const tSelector = perfStart()
// Pre-filter: only call expensive getProps() on children with non-default effects.
// The effect/trigger check is a cheap property read — skip children that would be
Expand Down Expand Up @@ -169,7 +171,7 @@ async function computeSelectorProps(node: ComponentSetNode): Promise<{
variants: Record<string, string>
variantComments: Record<string, string>
} = { props: {}, variants: {}, variantComments: {} }
const defs = node.componentPropertyDefinitions
const defs = propDefs
for (const name in defs) {
if (name === 'effect' || name === 'viewport') continue
const definition = defs[name]
Expand Down Expand Up @@ -233,7 +235,8 @@ export async function getSelectorPropsForGroup(
variantFilter: Record<string, string>,
viewportValue?: string,
): Promise<Record<string, object | string>> {
const hasEffect = !!componentSet.componentPropertyDefinitions.effect
const setDefs = getComponentPropertyDefinitions(componentSet)
const hasEffect = !!setDefs.effect
if (!hasEffect) return {}

// Build cache key from componentSet.id + filter + viewport
Expand Down Expand Up @@ -269,9 +272,10 @@ async function computeSelectorPropsForGroup(
viewportValue?: string,
): Promise<Record<string, object | string>> {
// Find viewport key if needed
const viewportKey = Object.keys(
componentSet.componentPropertyDefinitions,
).find((key) => key.toLowerCase() === 'viewport')
const groupDefs = getComponentPropertyDefinitions(componentSet)
const viewportKey = Object.keys(groupDefs).find(
(key) => key.toLowerCase() === 'viewport',
)

// Filter components matching the variant filter (and viewport if specified)
const matchingComponents = componentSet.children.filter((child) => {
Expand Down
25 changes: 15 additions & 10 deletions src/codegen/responsive/ResponsiveCodegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '../props/selector'
import { renderComponent, renderNode } from '../render'
import type { NodeTree, Props } from '../types'
import { getComponentPropertyDefinitions } from '../utils/get-component-property-definitions'
import { paddingLeftMultiline } from '../utils/padding-left-multiline'
import { perfEnd, perfStart } from '../utils/perf'
import {
Expand Down Expand Up @@ -399,9 +400,10 @@ export class ResponsiveCodegen {
componentName: string,
): Promise<ReadonlyArray<readonly [string, string]>> {
// Find viewport and effect variant keys
const viewportDefs = getComponentPropertyDefinitions(componentSet)
let viewportKey: string | undefined
let effectKey: string | undefined
for (const key in componentSet.componentPropertyDefinitions) {
for (const key in viewportDefs) {
const lower = key.toLowerCase()
if (lower === 'viewport') viewportKey = key
else if (lower === 'effect') effectKey = key
Expand All @@ -413,8 +415,8 @@ export class ResponsiveCodegen {

// Get variants excluding viewport
const variants: Record<string, string> = {}
for (const name in componentSet.componentPropertyDefinitions) {
const definition = componentSet.componentPropertyDefinitions[name]
for (const name in viewportDefs) {
const definition = viewportDefs[name]
const lowerName = name.toLowerCase()
if (lowerName !== 'viewport' && lowerName !== 'effect') {
const sanitizedName = sanitizePropertyName(name)
Expand Down Expand Up @@ -550,9 +552,10 @@ export class ResponsiveCodegen {
const tTotal = perfStart()

// Find viewport and effect variant keys
const variantDefs = getComponentPropertyDefinitions(componentSet)
let viewportKey: string | undefined
let effectKey: string | undefined
for (const key in componentSet.componentPropertyDefinitions) {
for (const key in variantDefs) {
const lower = key.toLowerCase()
if (lower === 'viewport') viewportKey = key
else if (lower === 'effect') effectKey = key
Expand All @@ -563,8 +566,8 @@ export class ResponsiveCodegen {
const variants: Record<string, string> = {}
// Map from original name to sanitized name
const variantKeyToSanitized: Record<string, string> = {}
for (const name in componentSet.componentPropertyDefinitions) {
const definition = componentSet.componentPropertyDefinitions[name]
for (const name in variantDefs) {
const definition = variantDefs[name]
if (definition.type === 'VARIANT') {
const lowerName = name.toLowerCase()
// Exclude both viewport and effect from variant keys
Expand Down Expand Up @@ -794,8 +797,9 @@ export class ResponsiveCodegen {
// Collect BOOLEAN and INSTANCE_SWAP props for the interface
// (effect is handled via pseudo-selectors, VARIANT keys don't exist in effect-only path)
const variants: Record<string, string> = {}
for (const name in componentSet.componentPropertyDefinitions) {
const definition = componentSet.componentPropertyDefinitions[name]
const effectDefs = getComponentPropertyDefinitions(componentSet)
for (const name in effectDefs) {
const definition = effectDefs[name]
if (definition.type === 'INSTANCE_SWAP') {
variants[sanitizePropertyName(name)] = 'React.ReactNode'
} else if (definition.type === 'BOOLEAN') {
Expand Down Expand Up @@ -831,8 +835,9 @@ export class ResponsiveCodegen {
}

// Check if componentSet has effect variant (pseudo-selector)
const groupVariantDefs = getComponentPropertyDefinitions(componentSet)
let hasEffect = false
for (const key in componentSet.componentPropertyDefinitions) {
for (const key in groupVariantDefs) {
if (key.toLowerCase() === 'effect') {
hasEffect = true
break
Expand Down Expand Up @@ -905,7 +910,7 @@ export class ResponsiveCodegen {
if (hasEffect) {
const effectValue =
variantProps[
Object.keys(componentSet.componentPropertyDefinitions).find(
Object.keys(groupVariantDefs).find(
(k) => k.toLowerCase() === 'effect',
) || ''
]
Expand Down
14 changes: 14 additions & 0 deletions src/codegen/utils/__tests__/extract-instance-variant-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@ describe('extractInstanceVariantProps', () => {
expect(result.Viewport).toBeUndefined()
})

test('returns empty object when componentProperties getter throws', () => {
const node = {
get componentProperties(): never {
throw new Error(
'in get_componentProperties: Component set for node has existing errors',
)
},
} as unknown as InstanceNode

const result = extractInstanceVariantProps(node)

expect(result).toEqual({})
})

test('filters out both effect and viewport but keeps other variants', () => {
const node = {
componentProperties: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test'
import { getComponentPropertyDefinitions } from '../get-component-property-definitions'

describe('getComponentPropertyDefinitions', () => {
test('returns definitions from a valid node', () => {
const defs = {
status: {
type: 'VARIANT',
defaultValue: 'active',
variantOptions: ['active', 'inactive'],
},
}
const node = {
componentPropertyDefinitions: defs,
} as unknown as ComponentSetNode

expect(getComponentPropertyDefinitions(node)).toBe(
defs as ComponentPropertyDefinitions,
)
})

test('returns empty object when node is null', () => {
expect(getComponentPropertyDefinitions(null)).toEqual({})
})

test('returns empty object when node is undefined', () => {
expect(getComponentPropertyDefinitions(undefined)).toEqual({})
})

test('returns empty object when getter throws', () => {
const node = {
get componentPropertyDefinitions(): never {
throw new Error(
'in get_componentPropertyDefinitions: Component set has existing errors',
)
},
} as unknown as ComponentSetNode

expect(getComponentPropertyDefinitions(node)).toEqual({})
})
})
13 changes: 11 additions & 2 deletions src/codegen/utils/extract-instance-variant-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,20 @@ export function extractInstanceVariantProps(
): Record<string, unknown> {
const variantProps: Record<string, unknown> = {}

if (!node.componentProperties) {
let componentProperties: InstanceNode['componentProperties']
try {
componentProperties = node.componentProperties
} catch {
// Figma throws when the component set has validation errors
// (e.g. duplicate variant names, missing properties).
return variantProps
}

for (const [key, prop] of Object.entries(node.componentProperties)) {
if (!componentProperties) {
return variantProps
}

for (const [key, prop] of Object.entries(componentProperties)) {
if (isReservedVariantKey(key)) continue
const sanitizedKey = sanitizePropertyName(key)
if (prop.type === 'VARIANT') {
Expand Down
21 changes: 21 additions & 0 deletions src/codegen/utils/get-component-property-definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Safely access componentPropertyDefinitions on a node.
* Figma's getter throws when the component set has validation errors
* (e.g. duplicate variant names, missing properties).
* Returns an empty object on error so callers can iterate safely.
*/
export function getComponentPropertyDefinitions(
node: ComponentSetNode | ComponentNode | null | undefined,
): ComponentSetNode['componentPropertyDefinitions'] {
if (!node) {
return {} as ComponentSetNode['componentPropertyDefinitions']
}
try {
return (
node.componentPropertyDefinitions ||
({} as ComponentSetNode['componentPropertyDefinitions'])
)
} catch {
return {} as ComponentSetNode['componentPropertyDefinitions']
}
}