@dannote/figma-use 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,845 @@
1
+ /**
2
+ * React Reconciler that outputs Figma NodeChanges directly
3
+ *
4
+ * Renders React components directly to NodeChanges array
5
+ * ready for multiplayer WebSocket transmission.
6
+ *
7
+ * ## Key implementation notes (discovered via protocol sniffing)
8
+ *
9
+ * ### TEXT nodes require specific fields
10
+ * - fontName: { family, style, postscript } - ALWAYS required, even without explicit font
11
+ * - textAlignVertical: 'TOP' - required for height calculation
12
+ * - lineHeight: { value: 100, units: 'PERCENT' } - CRITICAL! Without this, height=0
13
+ * - textData: { characters: '...' } - text content wrapper
14
+ *
15
+ * ### Auto-layout field names differ from Plugin API
16
+ * - justifyContent → stackPrimaryAlignItems (not stackJustify)
17
+ * - alignItems → stackCounterAlignItems (not stackCounterAlign)
18
+ * - Valid values: 'MIN', 'CENTER', 'MAX', 'SPACE_EVENLY' (not 'SPACE_BETWEEN')
19
+ *
20
+ * ### Sizing modes for auto-layout
21
+ * - stackPrimarySizing/stackCounterSizing = 'FIXED' when explicit size given
22
+ * - stackPrimarySizing/stackCounterSizing = 'RESIZE_TO_FIT' for hug contents
23
+ * - Setting RESIZE_TO_FIT via multiplayer doesn't work; use Plugin API trigger-layout
24
+ *
25
+ * ### Component types
26
+ * - SYMBOL (15) = Component in Figma (historical name)
27
+ * - INSTANCE (16) = Component instance
28
+ * - ComponentSet = FRAME with isStateGroup=true (field 225)
29
+ *
30
+ * See also: component-set.tsx for ComponentSet/Instance linking issues
31
+ */
32
+
33
+ import Reconciler from 'react-reconciler'
34
+ import { consola } from 'consola'
35
+ import type { NodeChange, Paint } from '../multiplayer/codec.ts'
36
+ import { parseColor } from '../color.ts'
37
+ import { isVariable, resolveVariable, type FigmaVariable } from './vars.ts'
38
+ import { getComponentRegistry } from './components.tsx'
39
+ import {
40
+ getComponentSetRegistry,
41
+ generateVariantCombinations,
42
+ buildVariantName,
43
+ buildStateGroupPropertyValueOrders
44
+ } from './component-set.tsx'
45
+
46
+ // Track rendered components: symbol -> GUID
47
+ const renderedComponents = new Map<symbol, { sessionID: number; localID: number }>()
48
+
49
+ // Track rendered ComponentSets: symbol -> ComponentSet GUID
50
+ const renderedComponentSets = new Map<symbol, { sessionID: number; localID: number }>()
51
+ // Track variant component IDs within each ComponentSet
52
+ const renderedComponentSetVariants = new Map<symbol, Map<string, { sessionID: number; localID: number }>>()
53
+
54
+ // Pending ComponentSet instances to create via Plugin API
55
+ export interface PendingComponentSetInstance {
56
+ componentSetName: string
57
+ variantName: string
58
+ parentGUID: { sessionID: number; localID: number }
59
+ position: string
60
+ x: number
61
+ y: number
62
+ }
63
+ const pendingComponentSetInstances: PendingComponentSetInstance[] = []
64
+
65
+ export function getPendingComponentSetInstances(): PendingComponentSetInstance[] {
66
+ return [...pendingComponentSetInstances]
67
+ }
68
+
69
+ export function clearPendingComponentSetInstances() {
70
+ pendingComponentSetInstances.length = 0
71
+ }
72
+
73
+ export interface RenderOptions {
74
+ sessionID: number
75
+ parentGUID: { sessionID: number; localID: number }
76
+ startLocalID?: number
77
+ }
78
+
79
+ export interface RenderResult {
80
+ nodeChanges: NodeChange[]
81
+ nextLocalID: number
82
+ }
83
+
84
+ interface Instance {
85
+ type: string
86
+ props: Record<string, unknown>
87
+ localID: number
88
+ children: Instance[]
89
+ textContent?: string
90
+ }
91
+
92
+ interface Container {
93
+ options: RenderOptions
94
+ localIDCounter: number
95
+ children: Instance[]
96
+ }
97
+
98
+
99
+
100
+ function styleToNodeChange(
101
+ type: string,
102
+ props: Record<string, unknown>,
103
+ localID: number,
104
+ sessionID: number,
105
+ parentGUID: { sessionID: number; localID: number },
106
+ position: string,
107
+ textContent?: string
108
+ ): NodeChange {
109
+ const style = (props.style || {}) as Record<string, unknown>
110
+ const name = (props.name as string) || type
111
+
112
+ const nodeChange: NodeChange = {
113
+ guid: { sessionID, localID },
114
+ phase: 'CREATED',
115
+ parentIndex: { guid: parentGUID, position },
116
+ type: mapType(type),
117
+ name,
118
+ visible: true,
119
+ opacity: typeof style.opacity === 'number' ? style.opacity : 1,
120
+ }
121
+
122
+ // Size
123
+ const width = style.width ?? props.width
124
+ const height = style.height ?? props.height
125
+ if (width !== undefined && height !== undefined) {
126
+ nodeChange.size = { x: Number(width), y: Number(height) }
127
+ } else if (width !== undefined) {
128
+ nodeChange.size = { x: Number(width), y: 1 } // minimal height for auto-sizing
129
+ } else if (height !== undefined) {
130
+ nodeChange.size = { x: 1, y: Number(height) } // minimal width for auto-sizing
131
+ } else if (type !== 'TEXT') {
132
+ // Minimal size for auto-layout to expand from
133
+ nodeChange.size = { x: 1, y: 1 }
134
+ }
135
+
136
+ // Position (transform)
137
+ const x = Number(style.x ?? props.x ?? 0)
138
+ const y = Number(style.y ?? props.y ?? 0)
139
+ nodeChange.transform = {
140
+ m00: 1, m01: 0, m02: x,
141
+ m10: 0, m11: 1, m12: y,
142
+ }
143
+
144
+ // Background color → fill (supports Figma variables)
145
+ if (style.backgroundColor) {
146
+ const bgColor = style.backgroundColor
147
+ if (isVariable(bgColor)) {
148
+ const resolved = resolveVariable(bgColor)
149
+ // Use explicit value as fallback, or black
150
+ const fallback = bgColor.value ? parseColor(bgColor.value) : { r: 0, g: 0, b: 0, a: 1 }
151
+ nodeChange.fillPaints = [{
152
+ type: 'SOLID',
153
+ color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
154
+ opacity: 1,
155
+ visible: true,
156
+ colorVariableBinding: {
157
+ variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
158
+ }
159
+ } as Paint]
160
+ } else {
161
+ const color = parseColor(bgColor as string)
162
+ nodeChange.fillPaints = [{
163
+ type: 'SOLID',
164
+ color: { r: color.r, g: color.g, b: color.b, a: color.a },
165
+ opacity: color.a,
166
+ visible: true,
167
+ }]
168
+ }
169
+ }
170
+
171
+ // Border → stroke (supports Figma variables)
172
+ if (style.borderColor) {
173
+ const borderColor = style.borderColor
174
+ if (isVariable(borderColor)) {
175
+ const resolved = resolveVariable(borderColor)
176
+ const fallback = borderColor.value ? parseColor(borderColor.value) : { r: 0, g: 0, b: 0, a: 1 }
177
+ nodeChange.strokePaints = [{
178
+ type: 'SOLID',
179
+ color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
180
+ opacity: 1,
181
+ visible: true,
182
+ colorVariableBinding: {
183
+ variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
184
+ }
185
+ } as Paint]
186
+ } else {
187
+ const color = parseColor(borderColor as string)
188
+ nodeChange.strokePaints = [{
189
+ type: 'SOLID',
190
+ color: { r: color.r, g: color.g, b: color.b, a: color.a },
191
+ opacity: color.a,
192
+ visible: true,
193
+ }]
194
+ }
195
+ nodeChange.strokeWeight = Number(style.borderWidth ?? 1)
196
+ }
197
+
198
+ // Corner radius
199
+ if (style.borderRadius !== undefined) {
200
+ nodeChange.cornerRadius = Number(style.borderRadius)
201
+ }
202
+ if (style.borderTopLeftRadius !== undefined) {
203
+ nodeChange.rectangleTopLeftCornerRadius = Number(style.borderTopLeftRadius)
204
+ nodeChange.rectangleCornerRadiiIndependent = true
205
+ }
206
+ if (style.borderTopRightRadius !== undefined) {
207
+ nodeChange.rectangleTopRightCornerRadius = Number(style.borderTopRightRadius)
208
+ nodeChange.rectangleCornerRadiiIndependent = true
209
+ }
210
+ if (style.borderBottomLeftRadius !== undefined) {
211
+ nodeChange.rectangleBottomLeftCornerRadius = Number(style.borderBottomLeftRadius)
212
+ nodeChange.rectangleCornerRadiiIndependent = true
213
+ }
214
+ if (style.borderBottomRightRadius !== undefined) {
215
+ nodeChange.rectangleBottomRightCornerRadius = Number(style.borderBottomRightRadius)
216
+ nodeChange.rectangleCornerRadiiIndependent = true
217
+ }
218
+
219
+ // Auto-layout
220
+ if (style.flexDirection) {
221
+ nodeChange.stackMode = style.flexDirection === 'row' ? 'HORIZONTAL' : 'VERTICAL'
222
+ // Sizing mode determines if frame hugs content or has fixed size
223
+ // IMPORTANT: RESIZE_TO_FIT via multiplayer sets the MODE but doesn't recalculate size
224
+ // The actual resize happens in trigger-layout via Plugin API
225
+ // If explicit size given → FIXED, otherwise → RESIZE_TO_FIT (hug contents)
226
+ const isRow = style.flexDirection === 'row'
227
+ const primarySize = isRow ? width : height
228
+ const counterSize = isRow ? height : width
229
+ nodeChange.stackPrimarySizing = primarySize !== undefined ? 'FIXED' : 'RESIZE_TO_FIT'
230
+ nodeChange.stackCounterSizing = counterSize !== undefined ? 'FIXED' : 'RESIZE_TO_FIT'
231
+ }
232
+ if (style.gap !== undefined) {
233
+ nodeChange.stackSpacing = Number(style.gap)
234
+ }
235
+
236
+ // Padding
237
+ const pt = style.paddingTop ?? style.padding
238
+ const pr = style.paddingRight ?? style.padding
239
+ const pb = style.paddingBottom ?? style.padding
240
+ const pl = style.paddingLeft ?? style.padding
241
+
242
+ if (pt !== undefined) (nodeChange as unknown as Record<string, unknown>).stackVerticalPadding = Number(pt)
243
+ if (pl !== undefined) (nodeChange as unknown as Record<string, unknown>).stackHorizontalPadding = Number(pl)
244
+ if (pr !== undefined) nodeChange.stackPaddingRight = Number(pr)
245
+ if (pb !== undefined) nodeChange.stackPaddingBottom = Number(pb)
246
+
247
+ // Alignment - NOTE: field names differ from Plugin API!
248
+ // Plugin API uses primaryAxisAlignItems/counterAxisAlignItems
249
+ // Multiplayer uses stackPrimaryAlignItems/stackCounterAlignItems
250
+ // Also: 'SPACE_BETWEEN' doesn't exist in multiplayer, only 'SPACE_EVENLY'
251
+ if (style.justifyContent) {
252
+ const validValues: Record<string, string> = {
253
+ 'flex-start': 'MIN', 'center': 'CENTER', 'flex-end': 'MAX', 'space-evenly': 'SPACE_EVENLY'
254
+ }
255
+ const mapped = validValues[style.justifyContent as string]
256
+ if (mapped) {
257
+ nodeChange.stackPrimaryAlignItems = mapped
258
+ } else {
259
+ consola.warn(`justifyContent: "${style.justifyContent}" not supported, using "flex-start"`)
260
+ nodeChange.stackPrimaryAlignItems = 'MIN'
261
+ }
262
+ }
263
+ if (style.alignItems) {
264
+ const validValues: Record<string, string> = {
265
+ 'flex-start': 'MIN', 'center': 'CENTER', 'flex-end': 'MAX', 'stretch': 'STRETCH'
266
+ }
267
+ const mapped = validValues[style.alignItems as string]
268
+ if (mapped) {
269
+ nodeChange.stackCounterAlignItems = mapped
270
+ } else {
271
+ consola.warn(`alignItems: "${style.alignItems}" not supported, using "flex-start"`)
272
+ nodeChange.stackCounterAlignItems = 'MIN'
273
+ }
274
+ }
275
+
276
+ // Text-specific
277
+ if (type.toLowerCase() === 'text' && textContent) {
278
+ // Text content via textData.characters
279
+ const nc = nodeChange as unknown as Record<string, unknown>
280
+ nc.textData = { characters: textContent }
281
+ nc.textAutoResize = 'WIDTH_AND_HEIGHT'
282
+ nc.textAlignVertical = 'TOP' // Required for text height calculation
283
+
284
+ if (style.fontSize) nc.fontSize = Number(style.fontSize)
285
+ // CRITICAL: lineHeight MUST be { value: 100, units: 'PERCENT' } for text to have height
286
+ // Without this, TEXT nodes render with height=0 and are invisible
287
+ // Discovered via sniffing Figma's own text creation - see scripts/sniff-text.ts
288
+ nc.lineHeight = { value: 100, units: 'PERCENT' }
289
+ // fontName is ALWAYS required for TEXT nodes, even without explicit fontFamily
290
+ const family = (style.fontFamily as string) || 'Inter'
291
+ const fontStyle = mapFontWeight(style.fontWeight as string)
292
+ nc.fontName = {
293
+ family,
294
+ style: fontStyle,
295
+ postscript: `${family}-${fontStyle}`.replace(/\s+/g, ''),
296
+ }
297
+ if (style.textAlign) {
298
+ const map: Record<string, string> = { 'left': 'LEFT', 'center': 'CENTER', 'right': 'RIGHT' }
299
+ nc.textAlignHorizontal = map[style.textAlign as string] || 'LEFT'
300
+ }
301
+ if (style.color) {
302
+ const textColor = style.color
303
+ if (isVariable(textColor)) {
304
+ const resolved = resolveVariable(textColor)
305
+ const fallback = textColor.value ? parseColor(textColor.value) : { r: 0, g: 0, b: 0, a: 1 }
306
+ nodeChange.fillPaints = [{
307
+ type: 'SOLID',
308
+ color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
309
+ opacity: 1,
310
+ visible: true,
311
+ colorVariableBinding: {
312
+ variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
313
+ }
314
+ } as Paint]
315
+ } else {
316
+ const color = parseColor(textColor as string)
317
+ nodeChange.fillPaints = [{
318
+ type: 'SOLID',
319
+ color: { r: color.r, g: color.g, b: color.b, a: color.a },
320
+ opacity: color.a,
321
+ visible: true,
322
+ }]
323
+ }
324
+ }
325
+ }
326
+
327
+ // Instance - link to component
328
+ if (type.toLowerCase() === 'instance' && props.componentId) {
329
+ const match = String(props.componentId).match(/(\d+):(\d+)/)
330
+ if (match) {
331
+ const nc = nodeChange as unknown as Record<string, unknown>
332
+ nc.symbolData = {
333
+ symbolID: {
334
+ sessionID: parseInt(match[1], 10),
335
+ localID: parseInt(match[2], 10),
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ return nodeChange
342
+ }
343
+
344
+ function mapType(type: string): string {
345
+ const map: Record<string, string> = {
346
+ frame: 'FRAME',
347
+ rectangle: 'RECTANGLE',
348
+ ellipse: 'ELLIPSE',
349
+ text: 'TEXT',
350
+ line: 'LINE',
351
+ star: 'STAR',
352
+ polygon: 'REGULAR_POLYGON',
353
+ vector: 'VECTOR',
354
+ component: 'SYMBOL', // Figma internally calls components "symbols"
355
+ instance: 'INSTANCE',
356
+ group: 'GROUP',
357
+ page: 'CANVAS',
358
+ }
359
+ return map[type.toLowerCase()] || 'FRAME'
360
+ }
361
+
362
+ function mapFontWeight(weight?: string): string {
363
+ if (!weight) return 'Regular'
364
+ const map: Record<string, string> = {
365
+ 'normal': 'Regular',
366
+ 'bold': 'Bold',
367
+ '100': 'Thin',
368
+ '200': 'Extra Light',
369
+ '300': 'Light',
370
+ '400': 'Regular',
371
+ '500': 'Medium',
372
+ '600': 'Semi Bold',
373
+ '700': 'Bold',
374
+ '800': 'Extra Bold',
375
+ '900': 'Black',
376
+ }
377
+ return map[weight] || 'Regular'
378
+ }
379
+
380
+ function collectNodeChanges(
381
+ instance: Instance,
382
+ sessionID: number,
383
+ parentGUID: { sessionID: number; localID: number },
384
+ position: string,
385
+ result: NodeChange[],
386
+ container: Container
387
+ ): void {
388
+ // Handle defineComponent instances
389
+ if (instance.type === '__component_instance__') {
390
+ const sym = instance.props.__componentSymbol as symbol
391
+ const name = instance.props.__componentName as string
392
+ const registry = getComponentRegistry()
393
+ const def = registry.get(sym)
394
+
395
+ if (!def) {
396
+ consola.error(`Component "${name}" not found in registry`)
397
+ return
398
+ }
399
+
400
+ // Check if component already rendered
401
+ let componentGUID = renderedComponents.get(sym)
402
+
403
+ if (!componentGUID) {
404
+ // First instance: render as Component
405
+ const componentLocalID = container.localIDCounter++
406
+ componentGUID = { sessionID, localID: componentLocalID }
407
+ renderedComponents.set(sym, componentGUID)
408
+
409
+ // Render the component's element tree
410
+ const componentResult = renderToNodeChanges(def.element, {
411
+ sessionID,
412
+ parentGUID, // Will be fixed below
413
+ startLocalID: container.localIDCounter,
414
+ })
415
+
416
+ // Update counter
417
+ container.localIDCounter = componentResult.nextLocalID
418
+
419
+ // Change first node to be SYMBOL type and add to results
420
+ if (componentResult.nodeChanges.length > 0) {
421
+ const rootChange = componentResult.nodeChanges[0]
422
+ const originalRootGUID = { ...rootChange.guid }
423
+
424
+ // Replace root node's guid with componentGUID
425
+ rootChange.guid = componentGUID
426
+ rootChange.type = 'SYMBOL'
427
+ rootChange.name = name
428
+ rootChange.parentIndex = { guid: parentGUID, position }
429
+
430
+ // Fix children's parentIndex to point to componentGUID instead of original root
431
+ for (let i = 1; i < componentResult.nodeChanges.length; i++) {
432
+ const child = componentResult.nodeChanges[i]
433
+ if (child.parentIndex?.guid.localID === originalRootGUID.localID &&
434
+ child.parentIndex?.guid.sessionID === originalRootGUID.sessionID) {
435
+ child.parentIndex.guid = componentGUID
436
+ }
437
+ }
438
+
439
+ // Merge style props from instance onto component
440
+ const style = (instance.props.style || {}) as Record<string, unknown>
441
+ if (style.x !== undefined || style.y !== undefined) {
442
+ const x = Number(style.x ?? 0)
443
+ const y = Number(style.y ?? 0)
444
+ rootChange.transform = { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y }
445
+ }
446
+
447
+ result.push(...componentResult.nodeChanges)
448
+ }
449
+ } else {
450
+ // Subsequent instance: create Instance node
451
+ const instanceLocalID = container.localIDCounter++
452
+ const style = (instance.props.style || {}) as Record<string, unknown>
453
+ const x = Number(style.x ?? 0)
454
+ const y = Number(style.y ?? 0)
455
+
456
+ const instanceChange: NodeChange = {
457
+ guid: { sessionID, localID: instanceLocalID },
458
+ phase: 'CREATED',
459
+ parentIndex: { guid: parentGUID, position },
460
+ type: 'INSTANCE',
461
+ name,
462
+ visible: true,
463
+ opacity: 1,
464
+ transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
465
+ }
466
+
467
+ // Link to component
468
+ const nc = instanceChange as unknown as Record<string, unknown>
469
+ nc.symbolData = { symbolID: componentGUID }
470
+
471
+ result.push(instanceChange)
472
+ }
473
+ return
474
+ }
475
+
476
+ // Handle defineComponentSet instances
477
+ if (instance.type === '__component_set_instance__') {
478
+ const sym = instance.props.__componentSetSymbol as symbol
479
+ const name = instance.props.__componentSetName as string
480
+ const variantProps = (instance.props.__variantProps || {}) as Record<string, string>
481
+ const csRegistry = getComponentSetRegistry()
482
+ const csDef = csRegistry.get(sym)
483
+
484
+ if (!csDef) {
485
+ consola.error(`ComponentSet "${name}" not found in registry`)
486
+ return
487
+ }
488
+
489
+ // Check if ComponentSet already rendered
490
+ let componentSetGUID = renderedComponentSets.get(sym)
491
+
492
+ if (!componentSetGUID) {
493
+ // First instance: create ComponentSet with all variant components
494
+ const componentSetLocalID = container.localIDCounter++
495
+ componentSetGUID = { sessionID, localID: componentSetLocalID }
496
+ renderedComponentSets.set(sym, componentSetGUID)
497
+
498
+ const variants = csDef.variants
499
+ const combinations = generateVariantCombinations(variants)
500
+ const variantComponentIds = new Map<string, { sessionID: number; localID: number }>()
501
+
502
+ // Create ComponentSet node (FRAME with isStateGroup)
503
+ const setChange: NodeChange = {
504
+ guid: componentSetGUID,
505
+ phase: 'CREATED',
506
+ parentIndex: { guid: parentGUID, position },
507
+ type: 'FRAME',
508
+ name,
509
+ visible: true,
510
+ opacity: 1,
511
+ size: { x: 1, y: 1 }, // Will be auto-sized
512
+ }
513
+ const setNc = setChange as unknown as Record<string, unknown>
514
+ setNc.isStateGroup = true
515
+ setNc.stateGroupPropertyValueOrders = buildStateGroupPropertyValueOrders(variants)
516
+ setNc.stackMode = 'HORIZONTAL'
517
+ setNc.stackSpacing = 20
518
+ setNc.stackPrimarySizing = 'RESIZE_TO_FIT'
519
+ setNc.stackCounterSizing = 'RESIZE_TO_FIT'
520
+
521
+ result.push(setChange)
522
+
523
+ // Create Component for each variant combination
524
+ combinations.forEach((combo, i) => {
525
+ const variantName = buildVariantName(combo)
526
+ const variantLocalID = container.localIDCounter++
527
+ const variantGUID = { sessionID, localID: variantLocalID }
528
+ variantComponentIds.set(variantName, variantGUID)
529
+
530
+ // Render the variant's element
531
+ const variantElement = csDef.render(combo)
532
+ const variantResult = renderToNodeChanges(variantElement, {
533
+ sessionID,
534
+ parentGUID: componentSetGUID!,
535
+ startLocalID: container.localIDCounter,
536
+ })
537
+ container.localIDCounter = variantResult.nextLocalID
538
+
539
+ if (variantResult.nodeChanges.length > 0) {
540
+ const rootChange = variantResult.nodeChanges[0]
541
+ const originalRootGUID = { ...rootChange.guid }
542
+
543
+ rootChange.guid = variantGUID
544
+ rootChange.type = 'SYMBOL'
545
+ rootChange.name = variantName
546
+ rootChange.parentIndex = {
547
+ guid: componentSetGUID!,
548
+ position: String.fromCharCode(33 + i)
549
+ }
550
+
551
+ // Fix children's parentIndex
552
+ for (let j = 1; j < variantResult.nodeChanges.length; j++) {
553
+ const child = variantResult.nodeChanges[j]
554
+ if (child.parentIndex?.guid.localID === originalRootGUID.localID &&
555
+ child.parentIndex?.guid.sessionID === originalRootGUID.sessionID) {
556
+ child.parentIndex.guid = variantGUID
557
+ }
558
+ }
559
+
560
+ result.push(...variantResult.nodeChanges)
561
+ }
562
+ })
563
+
564
+ // Store variant IDs for creating instances
565
+ renderedComponentSetVariants.set(sym, variantComponentIds)
566
+
567
+ // Store pending instances to create via Plugin API (multiplayer symbolData doesn't work for ComponentSet)
568
+ const requestedVariantName = buildVariantName({
569
+ ...getDefaultVariants(variants),
570
+ ...variantProps
571
+ })
572
+ const style = (instance.props.style || {}) as Record<string, unknown>
573
+ pendingComponentSetInstances.push({
574
+ componentSetName: name,
575
+ variantName: requestedVariantName,
576
+ parentGUID,
577
+ position: String.fromCharCode(34 + combinations.length),
578
+ x: Number(style.x ?? 0),
579
+ y: Number(style.y ?? 0),
580
+ })
581
+ } else {
582
+ // Subsequent instance: store for Plugin API creation
583
+ const requestedVariantName = buildVariantName({
584
+ ...getDefaultVariants(csDef.variants),
585
+ ...variantProps
586
+ })
587
+ const style = (instance.props.style || {}) as Record<string, unknown>
588
+ pendingComponentSetInstances.push({
589
+ componentSetName: name,
590
+ variantName: requestedVariantName,
591
+ parentGUID,
592
+ position,
593
+ x: Number(style.x ?? 0),
594
+ y: Number(style.y ?? 0),
595
+ })
596
+ }
597
+ return
598
+ }
599
+
600
+ const nodeChange = styleToNodeChange(
601
+ instance.type,
602
+ instance.props,
603
+ instance.localID,
604
+ sessionID,
605
+ parentGUID,
606
+ position,
607
+ instance.textContent
608
+ )
609
+ result.push(nodeChange)
610
+
611
+ const thisGUID = { sessionID, localID: instance.localID }
612
+ instance.children.forEach((child, i) => {
613
+ const childPosition = String.fromCharCode(33 + (i % 90))
614
+ collectNodeChanges(child, sessionID, thisGUID, childPosition, result, container)
615
+ })
616
+ }
617
+
618
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
619
+ const hostConfig: any = {
620
+ supportsMutation: true,
621
+ supportsPersistence: false,
622
+ supportsHydration: false,
623
+ isPrimaryRenderer: true,
624
+
625
+ now: Date.now,
626
+ scheduleTimeout: setTimeout,
627
+ cancelTimeout: clearTimeout,
628
+ noTimeout: -1 as const,
629
+
630
+ getRootHostContext() {
631
+ return {}
632
+ },
633
+
634
+ getChildHostContext() {
635
+ return {}
636
+ },
637
+
638
+ shouldSetTextContent() {
639
+ return false
640
+ },
641
+
642
+ createInstance(
643
+ type: string,
644
+ props: Record<string, unknown>,
645
+ _rootContainer: Container,
646
+ ): Instance {
647
+ const { children: _, ...rest } = props
648
+ return {
649
+ type,
650
+ props: rest,
651
+ localID: _rootContainer.localIDCounter++,
652
+ children: [],
653
+ }
654
+ },
655
+
656
+ createTextInstance(
657
+ text: string,
658
+ ): Instance {
659
+ return {
660
+ type: '__text__',
661
+ props: {},
662
+ localID: -1,
663
+ children: [],
664
+ textContent: text,
665
+ }
666
+ },
667
+
668
+ appendInitialChild(parent: Instance, child: Instance): void {
669
+ if (child.type === '__text__') {
670
+ parent.textContent = (parent.textContent || '') + (child.textContent || '')
671
+ } else {
672
+ parent.children.push(child)
673
+ }
674
+ },
675
+
676
+ appendChild(parent: Instance, child: Instance): void {
677
+ if (child.type === '__text__') {
678
+ parent.textContent = (parent.textContent || '') + (child.textContent || '')
679
+ } else {
680
+ parent.children.push(child)
681
+ }
682
+ },
683
+
684
+ appendChildToContainer(container: Container, child: Instance): void {
685
+ if (child.type !== '__text__') {
686
+ container.children.push(child)
687
+ }
688
+ },
689
+
690
+ removeChild(parent: Instance, child: Instance): void {
691
+ const index = parent.children.indexOf(child)
692
+ if (index !== -1) parent.children.splice(index, 1)
693
+ },
694
+
695
+ removeChildFromContainer(container: Container, child: Instance): void {
696
+ const index = container.children.indexOf(child)
697
+ if (index !== -1) container.children.splice(index, 1)
698
+ },
699
+
700
+ insertBefore(parent: Instance, child: Instance, beforeChild: Instance): void {
701
+ if (child.type === '__text__') return
702
+ const index = parent.children.indexOf(beforeChild)
703
+ if (index !== -1) {
704
+ parent.children.splice(index, 0, child)
705
+ } else {
706
+ parent.children.push(child)
707
+ }
708
+ },
709
+
710
+ insertInContainerBefore(container: Container, child: Instance, beforeChild: Instance): void {
711
+ if (child.type === '__text__') return
712
+ const index = container.children.indexOf(beforeChild)
713
+ if (index !== -1) {
714
+ container.children.splice(index, 0, child)
715
+ } else {
716
+ container.children.push(child)
717
+ }
718
+ },
719
+
720
+ prepareForCommit(): Record<string, unknown> | null {
721
+ return null
722
+ },
723
+
724
+ resetAfterCommit(): void {},
725
+
726
+ clearContainer(container: Container): void {
727
+ container.children = []
728
+ },
729
+
730
+ finalizeInitialChildren() {
731
+ return false
732
+ },
733
+
734
+ prepareUpdate() {
735
+ return true
736
+ },
737
+
738
+ commitUpdate(
739
+ instance: Instance,
740
+ _updatePayload: unknown,
741
+ _type: string,
742
+ _prevProps: Record<string, unknown>,
743
+ nextProps: Record<string, unknown>
744
+ ): void {
745
+ const { children: _, ...rest } = nextProps
746
+ instance.props = rest
747
+ },
748
+
749
+ commitTextUpdate(
750
+ textInstance: Instance,
751
+ _oldText: string,
752
+ newText: string
753
+ ): void {
754
+ textInstance.textContent = newText
755
+ },
756
+
757
+ getPublicInstance(instance: Instance): Instance {
758
+ return instance
759
+ },
760
+
761
+ preparePortalMount() {},
762
+
763
+ getCurrentEventPriority() {
764
+ return 16 // DefaultEventPriority
765
+ },
766
+
767
+ getInstanceFromNode() {
768
+ return null
769
+ },
770
+
771
+ beforeActiveInstanceBlur() {},
772
+
773
+ afterActiveInstanceBlur() {},
774
+
775
+ prepareScopeUpdate() {},
776
+
777
+ getInstanceFromScope() {
778
+ return null
779
+ },
780
+
781
+ detachDeletedInstance() {},
782
+ }
783
+
784
+ /**
785
+ * Render a React element directly to NodeChanges
786
+ */
787
+ export function renderToNodeChanges(
788
+ element: React.ReactElement,
789
+ options: RenderOptions
790
+ ): RenderResult {
791
+ const container: Container = {
792
+ options,
793
+ localIDCounter: options.startLocalID ?? 1,
794
+ children: [],
795
+ }
796
+
797
+ const reconciler = Reconciler(hostConfig)
798
+
799
+ const root = reconciler.createContainer(
800
+ container,
801
+ 0, // tag: LegacyRoot
802
+ null, // hydrationCallbacks
803
+ false, // isStrictMode
804
+ null, // concurrentUpdatesByDefaultOverride
805
+ '', // identifierPrefix
806
+ () => {}, // onUncaughtError
807
+ () => {}, // onCaughtError
808
+ () => {}, // onRecoverableError
809
+ () => {}, // onDefaultTransitionIndicator
810
+ null // transitionCallbacks
811
+ )
812
+
813
+ reconciler.updateContainer(element, root, null, () => {})
814
+ reconciler.flushSync(() => {})
815
+
816
+ const nodeChanges: NodeChange[] = []
817
+ container.children.forEach((child, i) => {
818
+ const position = String.fromCharCode(33 + (i % 90))
819
+ collectNodeChanges(child, options.sessionID, options.parentGUID, position, nodeChanges, container)
820
+ })
821
+
822
+ return {
823
+ nodeChanges,
824
+ nextLocalID: container.localIDCounter,
825
+ }
826
+ }
827
+
828
+ // Get default variant values (first value for each property)
829
+ function getDefaultVariants(variants: Record<string, readonly string[]>): Record<string, string> {
830
+ const defaults: Record<string, string> = {}
831
+ for (const [key, values] of Object.entries(variants)) {
832
+ if (values.length > 0) {
833
+ defaults[key] = values[0]
834
+ }
835
+ }
836
+ return defaults
837
+ }
838
+
839
+ // Reset component tracking between renders
840
+ export function resetRenderedComponents() {
841
+ renderedComponents.clear()
842
+ renderedComponentSets.clear()
843
+ renderedComponentSetVariants.clear()
844
+ pendingComponentSetInstances.length = 0
845
+ }