@@ -17,6 +17,84 @@ import { getPageNode } from './utils/get-page-node'
1717import { perfEnd , perfStart } from './utils/perf'
1818import { 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+
2098export 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 ( [
0 commit comments