Skip to content

Commit 372592b

Browse files
committed
Optimize
1 parent 456a7e2 commit 372592b

File tree

14 files changed

+767
-528
lines changed

14 files changed

+767
-528
lines changed

src/__tests__/utils.test.ts

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
import { describe, expect, it, test } from 'bun:test'
2-
import { getComponentName, space } from '../utils'
1+
import { afterEach, describe, expect, it, test } from 'bun:test'
2+
import {
3+
getComponentName,
4+
propsToPropsWithTypography,
5+
resetTextStyleCache,
6+
space,
7+
} from '../utils'
8+
9+
// Minimal figma global for propsToPropsWithTypography tests
10+
if (!(globalThis as { figma?: unknown }).figma) {
11+
;(globalThis as { figma?: unknown }).figma = {
12+
getLocalTextStylesAsync: () => Promise.resolve([]),
13+
getStyleByIdAsync: () => Promise.resolve(null),
14+
} as unknown as typeof figma
15+
}
316

417
describe('space', () => {
518
it('should create space', () => {
@@ -9,6 +22,117 @@ describe('space', () => {
922
})
1023
})
1124

25+
describe('propsToPropsWithTypography', () => {
26+
afterEach(() => {
27+
resetTextStyleCache()
28+
})
29+
30+
it('should apply typography from resolved cache (sync fast path)', async () => {
31+
const origGetLocal = figma.getLocalTextStylesAsync
32+
const origGetStyle = figma.getStyleByIdAsync
33+
figma.getLocalTextStylesAsync = () =>
34+
Promise.resolve([{ id: 'ts-1' } as unknown as TextStyle]) as ReturnType<
35+
typeof figma.getLocalTextStylesAsync
36+
>
37+
figma.getStyleByIdAsync = (id: string) =>
38+
Promise.resolve(
39+
id === 'ts-1'
40+
? ({ id: 'ts-1', name: 'Typography/Body' } as unknown as BaseStyle)
41+
: null,
42+
) as ReturnType<typeof figma.getStyleByIdAsync>
43+
44+
// First call: populates async caches + resolved caches via .then()
45+
const r1 = await propsToPropsWithTypography(
46+
{ fontFamily: 'Arial', fontSize: 16, w: 100, h: 50 },
47+
'ts-1',
48+
)
49+
expect(r1.typography).toBe('body')
50+
expect(r1.fontFamily).toBeUndefined()
51+
expect(r1.w).toBeUndefined()
52+
53+
// Second call: hits sync resolved-value cache (lines 71-72)
54+
const r2 = await propsToPropsWithTypography(
55+
{ fontFamily: 'Inter', fontSize: 14, w: 200, h: 60 },
56+
'ts-1',
57+
)
58+
expect(r2.typography).toBe('body')
59+
expect(r2.fontFamily).toBeUndefined()
60+
expect(r2.w).toBeUndefined()
61+
62+
figma.getLocalTextStylesAsync = origGetLocal
63+
figma.getStyleByIdAsync = origGetStyle
64+
})
65+
66+
it('should return early from sync path when textStyleId not in resolved set', async () => {
67+
const origGetLocal = figma.getLocalTextStylesAsync
68+
const origGetStyle = figma.getStyleByIdAsync
69+
figma.getLocalTextStylesAsync = () =>
70+
Promise.resolve([{ id: 'ts-1' } as unknown as TextStyle]) as ReturnType<
71+
typeof figma.getLocalTextStylesAsync
72+
>
73+
figma.getStyleByIdAsync = () =>
74+
Promise.resolve(null) as ReturnType<typeof figma.getStyleByIdAsync>
75+
76+
// First call: populates resolved cache
77+
await propsToPropsWithTypography(
78+
{ fontFamily: 'Arial', w: 100, h: 50 },
79+
'ts-1',
80+
)
81+
82+
// Second call with unknown textStyleId — hits else branch (lines 75-76)
83+
const r = await propsToPropsWithTypography(
84+
{ fontFamily: 'Inter', w: 200, h: 60 },
85+
'ts-unknown',
86+
)
87+
expect(r.fontFamily).toBe('Inter')
88+
expect(r.typography).toBeUndefined()
89+
expect(r.w).toBeUndefined()
90+
91+
// Third call with empty textStyleId — also hits else branch
92+
const r2 = await propsToPropsWithTypography(
93+
{ fontFamily: 'Mono', w: 300, h: 70 },
94+
'',
95+
)
96+
expect(r2.fontFamily).toBe('Mono')
97+
expect(r2.typography).toBeUndefined()
98+
99+
figma.getLocalTextStylesAsync = origGetLocal
100+
figma.getStyleByIdAsync = origGetStyle
101+
})
102+
103+
it('should return props without typography when style resolves to null', async () => {
104+
const origGetLocal = figma.getLocalTextStylesAsync
105+
const origGetStyle = figma.getStyleByIdAsync
106+
figma.getLocalTextStylesAsync = () =>
107+
Promise.resolve([
108+
{ id: 'ts-null' } as unknown as TextStyle,
109+
]) as ReturnType<typeof figma.getLocalTextStylesAsync>
110+
figma.getStyleByIdAsync = () =>
111+
Promise.resolve(null) as ReturnType<typeof figma.getStyleByIdAsync>
112+
113+
// First call: populates caches, style is null
114+
const r1 = await propsToPropsWithTypography(
115+
{ fontFamily: 'Arial', fontSize: 16, w: 100, h: 50 },
116+
'ts-null',
117+
)
118+
expect(r1.typography).toBeUndefined()
119+
expect(r1.fontFamily).toBe('Arial')
120+
expect(r1.w).toBeUndefined()
121+
122+
// Second call: sync path, styleByIdResolved has null → style is falsy, skip applyTypography
123+
const r2 = await propsToPropsWithTypography(
124+
{ fontFamily: 'Inter', fontSize: 14, w: 200, h: 60 },
125+
'ts-null',
126+
)
127+
expect(r2.typography).toBeUndefined()
128+
expect(r2.fontFamily).toBe('Inter')
129+
expect(r2.w).toBeUndefined()
130+
131+
figma.getLocalTextStylesAsync = origGetLocal
132+
figma.getStyleByIdAsync = origGetStyle
133+
})
134+
})
135+
12136
describe('getComponentName', () => {
13137
test.each([
14138
{

src/codegen/Codegen.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,11 @@ export class Codegen {
227227
// Returns a CLONE because downstream code mutates tree.props.
228228
const globalCached = globalBuildTreeCache.get(cacheKey)
229229
if (globalCached) {
230-
const cloned = globalCached.then(cloneTree)
231-
this.buildTreeCache.set(cacheKey, cloned)
232-
return cloned
230+
const resolved = await globalCached
231+
const cloned = cloneTree(resolved)
232+
const clonedPromise = Promise.resolve(cloned)
233+
this.buildTreeCache.set(cacheKey, clonedPromise)
234+
return clonedPromise
233235
}
234236
}
235237
const promise = this.doBuildTree(node)

src/codegen/props/index.ts

Lines changed: 65 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,27 @@ import { getVisibilityProps } from './visibility'
2626
// For a COMPONENT_SET with N variants, getProps() is called O(N²) without caching
2727
// because getSelectorProps/getSelectorPropsForGroup call it on overlapping node sets.
2828
const getPropsCache = new Map<string, Promise<Record<string, unknown>>>()
29+
const getPropsResolved = new Map<string, Record<string, unknown>>()
2930

3031
export function resetGetPropsCache(): void {
3132
getPropsCache.clear()
33+
getPropsResolved.clear()
3234
}
3335

3436
export async function getProps(
3537
node: SceneNode,
3638
): Promise<Record<string, unknown>> {
3739
const cacheKey = node.id
3840
if (cacheKey) {
41+
// Sync fast path: return shallow clone from resolved cache (no microtask)
42+
const resolved = getPropsResolved.get(cacheKey)
43+
if (resolved) {
44+
perfEnd('getProps(cached)', perfStart())
45+
return { ...resolved }
46+
}
3947
const cached = getPropsCache.get(cacheKey)
4048
if (cached) {
4149
perfEnd('getProps(cached)', perfStart())
42-
// Return a shallow clone to prevent mutation of cached values
4350
return { ...(await cached) }
4451
}
4552
}
@@ -89,65 +96,75 @@ export async function getProps(
8996
perfEnd('getProps.sync', tSync)
9097

9198
// PHASE 3: Await async results — likely already resolved during sync phase.
92-
const [borderProps, backgroundProps, textStrokeProps, reactionProps] =
93-
await Promise.all([
94-
borderP.then((r) => {
95-
perfEnd('getProps.border', tBorder)
96-
return r
97-
}),
98-
bgP.then((r) => {
99-
perfEnd('getProps.background', tBg)
100-
return r
101-
}),
102-
textStrokeP
103-
? textStrokeP.then((r) => {
104-
perfEnd('getProps.textStroke', tTextStroke)
105-
return r
106-
})
107-
: undefined,
108-
reactionP.then((r) => {
109-
perfEnd('getProps.reaction', tReaction)
110-
return r
111-
}),
112-
])
99+
// Sequential await: all 4 promises are already in-flight, so this just
100+
// picks up resolved values in order without Promise.all + .then() overhead.
101+
const borderProps = await borderP
102+
perfEnd('getProps.border', tBorder)
103+
const backgroundProps = await bgP
104+
perfEnd('getProps.background', tBg)
105+
const textStrokeProps = textStrokeP ? await textStrokeP : undefined
106+
if (textStrokeP) perfEnd('getProps.textStroke', tTextStroke)
107+
const reactionProps = await reactionP
108+
perfEnd('getProps.reaction', tReaction)
113109

114110
// PHASE 4: Merge in the ORIGINAL interleaved order to preserve last-key-wins.
115111
// async results (border, background, effect, textStroke, textShadow, reaction)
116112
// are placed at their original positions relative to sync getters.
117-
return {
118-
...autoLayoutProps,
119-
...minMaxProps,
120-
...layoutProps,
121-
...borderRadiusProps,
122-
...borderProps,
123-
...backgroundProps,
124-
...blendProps,
125-
...paddingProps,
126-
...textAlignProps,
127-
...objectFitProps,
128-
...maxLineProps,
129-
...ellipsisProps,
130-
...effectProps,
131-
...positionProps,
132-
...gridChildProps,
133-
...transformProps,
134-
...overflowProps,
135-
...textStrokeProps,
136-
...textShadowProps,
137-
...reactionProps,
138-
...cursorProps,
139-
...visibilityProps,
140-
}
113+
const result: Record<string, unknown> = {}
114+
Object.assign(
115+
result,
116+
autoLayoutProps,
117+
minMaxProps,
118+
layoutProps,
119+
borderRadiusProps,
120+
)
121+
Object.assign(
122+
result,
123+
borderProps,
124+
backgroundProps,
125+
blendProps,
126+
paddingProps,
127+
)
128+
if (textAlignProps) Object.assign(result, textAlignProps)
129+
Object.assign(result, objectFitProps)
130+
if (maxLineProps) Object.assign(result, maxLineProps)
131+
if (ellipsisProps) Object.assign(result, ellipsisProps)
132+
Object.assign(
133+
result,
134+
effectProps,
135+
positionProps,
136+
gridChildProps,
137+
transformProps,
138+
)
139+
Object.assign(result, overflowProps)
140+
if (textStrokeProps) Object.assign(result, textStrokeProps)
141+
if (textShadowProps) Object.assign(result, textShadowProps)
142+
Object.assign(result, reactionProps, cursorProps, visibilityProps)
143+
return result
141144
})()
142145

143146
if (cacheKey) {
144147
getPropsCache.set(cacheKey, promise)
145148
}
146149
const result = await promise
150+
if (cacheKey) {
151+
getPropsResolved.set(cacheKey, result)
152+
}
147153
perfEnd('getProps()', t)
148154
return result
149155
}
150156

157+
const CENTER_SKIP_KEYS = new Set(['alignItems', 'justifyContent'])
158+
const IMAGE_BOX_SKIP_KEYS = new Set([
159+
'alignItems',
160+
'justifyContent',
161+
'flexDir',
162+
'gap',
163+
'outline',
164+
'outlineOffset',
165+
'overflow',
166+
])
167+
151168
export function filterPropsWithComponent(
152169
component: string,
153170
props: Record<string, unknown>,
@@ -165,7 +182,7 @@ export function filterPropsWithComponent(
165182
if (key === 'display' && value === 'grid') continue
166183
break
167184
case 'Center':
168-
if (['alignItems', 'justifyContent'].includes(key)) continue
185+
if (CENTER_SKIP_KEYS.has(key)) continue
169186
if (key === 'display' && value === 'flex') continue
170187
if (key === 'flexDir' && value === 'row') continue
171188
break
@@ -178,18 +195,7 @@ export function filterPropsWithComponent(
178195
case 'Image':
179196
case 'Box':
180197
if (component === 'Box' && !('maskImage' in props)) break
181-
if (
182-
[
183-
'alignItems',
184-
'justifyContent',
185-
'flexDir',
186-
'gap',
187-
'outline',
188-
'outlineOffset',
189-
'overflow',
190-
].includes(key)
191-
)
192-
continue
198+
if (IMAGE_BOX_SKIP_KEYS.has(key)) continue
193199
if (key === 'display' && value === 'flex') continue
194200
if (!('maskImage' in props) && ['bg'].includes(key)) continue
195201
break

0 commit comments

Comments
 (0)