Skip to content

Commit ff86ffb

Browse files
committed
Optimize
1 parent ec3020b commit ff86ffb

File tree

5 files changed

+462
-40
lines changed

5 files changed

+462
-40
lines changed

src/code-impl.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { Codegen } from './codegen/Codegen'
1+
import {
2+
Codegen,
3+
resetGlobalBuildTreeCache,
4+
resetMainComponentCache,
5+
} from './codegen/Codegen'
26
import { resetGetPropsCache } from './codegen/props'
37
import { resetSelectorPropsCache } from './codegen/props/selector'
48
import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen'
@@ -126,7 +130,9 @@ const debug = true
126130
export function registerCodegen(ctx: typeof figma) {
127131
if (ctx.editorType === 'dev' && ctx.mode === 'codegen') {
128132
ctx.codegen.on('generate', async ({ node: n, language }) => {
129-
const node = debug ? nodeProxyTracker.wrap(n) : n
133+
// Use the raw node for codegen (no Proxy overhead).
134+
// Debug tracking happens AFTER codegen completes via separate walk.
135+
const node = n
130136
switch (language) {
131137
case 'devup-ui': {
132138
const time = Date.now()
@@ -135,6 +141,8 @@ export function registerCodegen(ctx: typeof figma) {
135141
resetSelectorPropsCache()
136142
resetVariableCache()
137143
resetTextStyleCache()
144+
resetMainComponentCache()
145+
resetGlobalBuildTreeCache()
138146

139147
let t = perfStart()
140148
const codegen = new Codegen(node)
@@ -255,6 +263,9 @@ export function registerCodegen(ctx: typeof figma) {
255263
}
256264
}
257265
if (debug) {
266+
// Track AFTER codegen — collects all node properties for test case
267+
// generation without Proxy overhead during the hot codegen path.
268+
nodeProxyTracker.trackTree(node)
258269
console.log(
259270
await nodeProxyTracker.toTestCaseFormatWithVariables(node.id),
260271
)

src/codegen/Codegen.ts

Lines changed: 116 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,84 @@ import { getPageNode } from './utils/get-page-node'
1717
import { perfEnd, perfStart } from './utils/perf'
1818
import { buildCssUrl } from './utils/wrap-url'
1919

20+
// Global cache for node.getMainComponentAsync() results.
21+
// Multiple Codegen instances (from ResponsiveCodegen) process the same INSTANCE nodes,
22+
// each calling getMainComponentAsync which is an expensive Figma IPC call.
23+
// Keyed by instance node.id; stores the Promise to deduplicate concurrent calls.
24+
const mainComponentCache = new Map<string, Promise<ComponentNode | null>>()
25+
26+
export function resetMainComponentCache(): void {
27+
mainComponentCache.clear()
28+
}
29+
30+
function getMainComponentCached(
31+
node: InstanceNode,
32+
): Promise<ComponentNode | null> {
33+
const cacheKey = node.id
34+
if (cacheKey) {
35+
const cached = mainComponentCache.get(cacheKey)
36+
if (cached) return cached
37+
}
38+
const promise = node.getMainComponentAsync()
39+
if (cacheKey) {
40+
mainComponentCache.set(cacheKey, promise)
41+
}
42+
return promise
43+
}
44+
45+
// Global buildTree cache shared across all Codegen instances.
46+
// ResponsiveCodegen creates multiple Codegen instances for the same component
47+
// variants — without this, each instance rebuilds the entire subtree.
48+
// Returns cloned trees (shallow-cloned props at every level) because
49+
// downstream code mutates tree.props via Object.assign.
50+
const globalBuildTreeCache = new Map<string, Promise<NodeTree>>()
51+
52+
export function resetGlobalBuildTreeCache(): void {
53+
globalBuildTreeCache.clear()
54+
}
55+
56+
/**
57+
* Clone a NodeTree — shallow-clone props at every level so mutations
58+
* to one clone's props don't affect the cached original or other clones.
59+
*/
60+
function cloneTree(tree: NodeTree): NodeTree {
61+
return {
62+
component: tree.component,
63+
props: { ...tree.props },
64+
children: tree.children.map(cloneTree),
65+
nodeType: tree.nodeType,
66+
nodeName: tree.nodeName,
67+
isComponent: tree.isComponent,
68+
textChildren: tree.textChildren ? [...tree.textChildren] : undefined,
69+
}
70+
}
71+
72+
// Limited-concurrency worker pool for children processing.
73+
// Preserves output order while allowing N async operations in flight.
74+
const BUILD_CONCURRENCY = 2
75+
76+
async function mapConcurrent<T, R>(
77+
items: readonly T[],
78+
fn: (item: T, index: number) => Promise<R>,
79+
concurrency: number,
80+
): Promise<R[]> {
81+
if (items.length === 0) return []
82+
const results: R[] = new Array(items.length)
83+
let nextIndex = 0
84+
85+
async function worker() {
86+
while (nextIndex < items.length) {
87+
const i = nextIndex++
88+
results[i] = await fn(items[i], i)
89+
}
90+
}
91+
92+
await Promise.all(
93+
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
94+
)
95+
return results
96+
}
97+
2098
export class Codegen {
2199
components: Map<
22100
string,
@@ -99,16 +177,31 @@ export class Codegen {
99177
/**
100178
* Build a NodeTree representation of the node hierarchy.
101179
* This is the intermediate JSON representation that can be compared/merged.
180+
*
181+
* Uses a two-level cache:
182+
* 1. Global cache (across instances) — returns cloned trees to prevent mutation leaks
183+
* 2. Per-instance cache — returns the same promise within a single Codegen.run()
102184
*/
103185
async buildTree(node: SceneNode = this.node): Promise<NodeTree> {
104186
const cacheKey = node.id
105187
if (cacheKey) {
106-
const cached = this.buildTreeCache.get(cacheKey)
107-
if (cached) return cached
188+
// Per-instance cache (same tree object reused within one Codegen)
189+
const instanceCached = this.buildTreeCache.get(cacheKey)
190+
if (instanceCached) return instanceCached
191+
192+
// Global cache (shared across Codegen instances from ResponsiveCodegen).
193+
// Returns a CLONE because downstream code mutates tree.props.
194+
const globalCached = globalBuildTreeCache.get(cacheKey)
195+
if (globalCached) {
196+
const cloned = globalCached.then(cloneTree)
197+
this.buildTreeCache.set(cacheKey, cloned)
198+
return cloned
199+
}
108200
}
109201
const promise = this.doBuildTree(node)
110202
if (cacheKey) {
111203
this.buildTreeCache.set(cacheKey, promise)
204+
globalBuildTreeCache.set(cacheKey, promise)
112205
}
113206
return promise
114207
}
@@ -143,7 +236,7 @@ export class Codegen {
143236
// Handle INSTANCE nodes first — they only need position props (all sync),
144237
// skipping the expensive full getProps() with 6 async Figma API calls.
145238
if (node.type === 'INSTANCE') {
146-
const mainComponent = await node.getMainComponentAsync()
239+
const mainComponent = await getMainComponentCached(node)
147240
if (mainComponent) await this.addComponentTree(mainComponent)
148241

149242
const componentName = getComponentName(mainComponent || node)
@@ -210,16 +303,18 @@ export class Codegen {
210303
)
211304
}
212305

213-
// Build children sequentially to avoid Figma API contention.
306+
// Build children with limited concurrency (2 workers).
214307
// getProps(node) is already in-flight concurrently above.
215-
// INSTANCE children are handled inside doBuildTree when buildTree(child) recurses —
216-
// no pre-call to getMainComponentAsync needed (was causing duplicate Figma API calls).
217-
const children: NodeTree[] = []
218-
if ('children' in node) {
219-
for (const child of node.children) {
220-
children.push(await this.buildTree(child))
221-
}
222-
}
308+
// With variable/text-style/getProps caches in place, Figma API contention is low enough
309+
// for 2 concurrent subtree builds. This roughly halves wall-clock for wide trees.
310+
const children: NodeTree[] =
311+
'children' in node
312+
? await mapConcurrent(
313+
node.children,
314+
(child) => this.buildTree(child),
315+
BUILD_CONCURRENCY,
316+
)
317+
: []
223318

224319
// Now await props (likely already resolved while children were processing)
225320
const props = await propsPromise
@@ -294,14 +389,16 @@ export class Codegen {
294389
const t = perfStart()
295390
const selectorPropsPromise = getSelectorProps(node)
296391

297-
// Build children sequentially to avoid Figma API contention.
392+
// Build children with limited concurrency (same as doBuildTree).
298393
// INSTANCE children are handled inside doBuildTree when buildTree(child) recurses.
299-
const childrenTrees: NodeTree[] = []
300-
if ('children' in node) {
301-
for (const child of node.children) {
302-
childrenTrees.push(await this.buildTree(child))
303-
}
304-
}
394+
const childrenTrees: NodeTree[] =
395+
'children' in node
396+
? await mapConcurrent(
397+
node.children,
398+
(child) => this.buildTree(child),
399+
BUILD_CONCURRENCY,
400+
)
401+
: []
305402

306403
// Await props + selectorProps (likely already resolved while children built)
307404
const [props, selectorProps] = await Promise.all([

src/codegen/props/index.ts

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,8 @@ export async function getProps(
4848
const promise = (async () => {
4949
const isText = node.type === 'TEXT'
5050

51-
// Fire all async prop getters in parallel — they are independent
52-
// (no shared mutable state, no inter-function data dependencies).
53-
// Skip TEXT-only async getters for non-TEXT nodes.
51+
// PHASE 1: Fire all async prop getters — initiates Figma IPC calls immediately.
52+
// These return Promises that resolve when IPC completes.
5453
const tBorder = perfStart()
5554
const borderP = getBorderProps(node)
5655
const tBg = perfStart()
@@ -64,6 +63,30 @@ export async function getProps(
6463
const tReaction = perfStart()
6564
const reactionP = getReactionProps(node)
6665

66+
// PHASE 2: Run sync prop getters while async IPC is pending in background.
67+
// This overlaps ~129ms of sync work with ~17ms of async IPC wait.
68+
// Compute sync results eagerly; they'll be interleaved in the original merge
69+
// order below to preserve "last-key-wins" semantics.
70+
const tSync = perfStart()
71+
const autoLayoutProps = getAutoLayoutProps(node)
72+
const minMaxProps = getMinMaxProps(node)
73+
const layoutProps = getLayoutProps(node)
74+
const borderRadiusProps = getBorderRadiusProps(node)
75+
const blendProps = getBlendProps(node)
76+
const paddingProps = getPaddingProps(node)
77+
const textAlignProps = isText ? getTextAlignProps(node) : undefined
78+
const objectFitProps = getObjectFitProps(node)
79+
const maxLineProps = isText ? getMaxLineProps(node) : undefined
80+
const ellipsisProps = isText ? getEllipsisProps(node) : undefined
81+
const positionProps = getPositionProps(node)
82+
const gridChildProps = getGridChildProps(node)
83+
const transformProps = getTransformProps(node)
84+
const overflowProps = getOverflowProps(node)
85+
const cursorProps = getCursorProps(node)
86+
const visibilityProps = getVisibilityProps(node)
87+
perfEnd('getProps.sync', tSync)
88+
89+
// PHASE 3: Await async results — likely already resolved during sync phase.
6790
const [
6891
borderProps,
6992
backgroundProps,
@@ -102,29 +125,32 @@ export async function getProps(
102125
}),
103126
])
104127

128+
// PHASE 4: Merge in the ORIGINAL interleaved order to preserve last-key-wins.
129+
// async results (border, background, effect, textStroke, textShadow, reaction)
130+
// are placed at their original positions relative to sync getters.
105131
return {
106-
...getAutoLayoutProps(node),
107-
...getMinMaxProps(node),
108-
...getLayoutProps(node),
109-
...getBorderRadiusProps(node),
132+
...autoLayoutProps,
133+
...minMaxProps,
134+
...layoutProps,
135+
...borderRadiusProps,
110136
...borderProps,
111137
...backgroundProps,
112-
...getBlendProps(node),
113-
...getPaddingProps(node),
114-
...(isText ? getTextAlignProps(node) : undefined),
115-
...getObjectFitProps(node),
116-
...(isText ? getMaxLineProps(node) : undefined),
117-
...(isText ? getEllipsisProps(node) : undefined),
138+
...blendProps,
139+
...paddingProps,
140+
...textAlignProps,
141+
...objectFitProps,
142+
...maxLineProps,
143+
...ellipsisProps,
118144
...effectProps,
119-
...getPositionProps(node),
120-
...getGridChildProps(node),
121-
...getTransformProps(node),
122-
...getOverflowProps(node),
145+
...positionProps,
146+
...gridChildProps,
147+
...transformProps,
148+
...overflowProps,
123149
...textStrokeProps,
124150
...textShadowProps,
125151
...reactionProps,
126-
...getCursorProps(node),
127-
...getVisibilityProps(node),
152+
...cursorProps,
153+
...visibilityProps,
128154
}
129155
})()
130156

0 commit comments

Comments
 (0)