diff --git a/packages/core/src/kiwi/instance-overrides.ts b/packages/core/src/kiwi/instance-overrides.ts index 17023b434..72d64bd9e 100644 --- a/packages/core/src/kiwi/instance-overrides.ts +++ b/packages/core/src/kiwi/instance-overrides.ts @@ -64,38 +64,55 @@ export function populateAndApplyOverrides( guidToNodeId: Map, blobs: Uint8Array[] = [] ): void { - // Work-queue population: seed with empty instances, then discover newly - // cloned nested instances by walking the subtree (avoids full graph scan per round). - const populateQueue: string[] = [] - for (const node of graph.getAllNodes()) { - if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { - populateQueue.push(node.id) - } - } + // Populate empty INSTANCE nodes from their source components. Instances + // must be populated bottom-up: if an instance's source is itself an + // unpopulated instance, populate the source first so cloned children + // are complete. + function ensurePopulated(nodeId: string, visiting: Set): void { + const node = graph.getNode(nodeId) + if (!node || node.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) return + if (visiting.has(nodeId)) return + visiting.add(nodeId) - function collectEmptyInstances(parentId: string, out: string[]) { - const parent = graph.getNode(parentId) - if (!parent) return - for (const childId of parent.childIds) { + const comp = graph.getNode(node.componentId) + if (!comp) return + + // If the source is an unpopulated instance, populate it first + if (comp.type === 'INSTANCE' && comp.componentId && comp.childIds.length === 0) { + ensurePopulated(comp.id, visiting) + } + // Also ensure children of the source are populated (nested instances) + for (const childId of comp.childIds) { const child = graph.getNode(childId) - if (!child) continue - if (child.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) { - out.push(child.id) - } else if (child.childIds.length > 0) { - collectEmptyInstances(childId, out) + if (child?.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) { + ensurePopulated(childId, visiting) } } + + if (comp.childIds.length > 0 && node.childIds.length === 0) { + graph.populateInstanceChildren(nodeId, node.componentId) + } } - while (populateQueue.length > 0) { - const nodeId = populateQueue.pop() - if (!nodeId) continue - const node = graph.getNode(nodeId) - if (node?.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) continue - const comp = graph.getNode(node.componentId) - if (comp && comp.childIds.length > 0) { - graph.populateInstanceChildren(nodeId, node.componentId) - collectEmptyInstances(nodeId, populateQueue) + const visiting = new Set() + for (const node of graph.getAllNodes()) { + if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { + ensurePopulated(node.id, visiting) + } + } + // Second pass: cloning may have introduced new empty instances not seen + // in the first pass (nested clones). Repeat until stable. + let changed = true + while (changed) { + changed = false + for (const node of graph.getAllNodes()) { + if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { + const comp = graph.getNode(node.componentId) + if (comp && comp.childIds.length > 0) { + graph.populateInstanceChildren(node.id, node.componentId) + changed = true + } + } } } @@ -282,15 +299,24 @@ export function populateAndApplyOverrides( } } - // Also apply from cloned instance sources — after population, cloned - // instances have componentId pointing to the original kiwi node + // Walk the componentId chain to find a kiwi source with assignments. + // Cloned instances may be several levels deep (clone of clone of …), + // so a single-hop lookup is insufficient. if (!node.componentId) continue - const sourceFigmaId = nodeIdToGuid.get(node.componentId) - if (!sourceFigmaId) continue - const assignments = assignmentSources.get(sourceFigmaId) - if (!assignments) continue - - applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap) + let sourceId: string | undefined = node.componentId + for (let depth = 0; sourceId && depth < 20; depth++) { + const figmaId = nodeIdToGuid.get(sourceId) + if (figmaId) { + const assignments = assignmentSources.get(figmaId) + if (assignments) { + applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap) + break + } + } + const n = graph.getNode(sourceId) + if (!n?.componentId || n.componentId === sourceId) break + sourceId = n.componentId + } } } @@ -528,7 +554,8 @@ export function populateAndApplyOverrides( overriddenNodes.add(targetId) if (ov.overriddenSymbolID) { - const newCompId = guidToNodeId.get(guidToString(ov.overriddenSymbolID)) + const swapGuid = guidToString(ov.overriddenSymbolID) + const newCompId = guidToNodeId.get(swapGuid) if (newCompId) repopulateInstance(targetId, newCompId) } @@ -666,11 +693,9 @@ export function populateAndApplyOverrides( } // Order matters: - // 1. symbolOverrides — set property values and swap instances - // 2. transitive sync — propagate overrides through clone chains (may - // repopulate INSTANCE children, wiping any earlier property changes) + // 1. symbolOverrides — set property values and swap instances (kiwi + clones) + // 2. transitive sync — propagate overrides through remaining clone chains // 3. componentProperties — toggle visibility / swap via prop assignments - // (must run AFTER sync so repopulated children aren't lost) // 4. derivedSymbolData — apply Figma's pre-computed sizes last const overriddenNodes = applySymbolOverrides() @@ -678,7 +703,5 @@ export function populateAndApplyOverrides( applyComponentProperties() - // DSD resolution runs AFTER overrides so guidPaths can reach children - // of instance-swapped nodes (repopulateInstance replaces children). applyDerivedSymbolData() }