fix(fig): propagate cloned component property overrides

- Resolve component-property refs through override keys on cloned children
- Apply instance component-property assignments through clone chains
- Use child shadow shapes for containers without visible fills
- Keep clipped export bounds from leaking hidden descendants
This commit is contained in:
Danila Poyarkov 2026-05-20 12:29:51 +03:00
parent ffa0b9d6f8
commit 82fc5fb7bb
7 changed files with 186 additions and 21 deletions

View file

@ -303,7 +303,7 @@ export function renderShape(
* Returns the child to use for shadow shape, or null to use the node itself.
*/
function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | null {
if (node.fills.some((f) => f.visible) || node.fillGeometry.length > 0) return null
if (node.fills.some((f) => f.visible)) return null
if (node.childIds.length === 0) return null
const child = graph.getNode(node.childIds[0])
if (!child?.visible) return null

View file

@ -365,6 +365,7 @@ function collectDescendantVisualBounds(
maxY: abs.y + node.height
}
childClip = childClip ? intersectVisualBounds(childClip, nodeClip) : nodeClip
if (!childClip) return bounds
}
for (const childId of node.childIds ?? []) {

View file

@ -0,0 +1,33 @@
import type { OverrideContext } from './types'
export function buildCloneIndex(ctx: OverrideContext): Map<string, string[]> {
const clonesBySource = new Map<string, string[]>()
for (const node of ctx.graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId) continue
if (ctx.activeNodeIds && !ctx.activeNodeIds.has(node.id)) continue
const clones = clonesBySource.get(node.componentId)
if (clones) clones.push(node.id)
else clonesBySource.set(node.componentId, [node.id])
}
return clonesBySource
}
export function instanceAndClones(
instanceNodeId: string,
clonesBySource: Map<string, string[]>,
cache: Map<string, string[]>
): string[] {
const cached = cache.get(instanceNodeId)
if (cached) return cached
const result: string[] = []
const seen = new Set<string>()
const visit = (id: string) => {
if (seen.has(id)) return
seen.add(id)
result.push(id)
for (const cloneId of clonesBySource.get(id) ?? []) visit(cloneId)
}
visit(instanceNodeId)
cache.set(instanceNodeId, result)
return result
}

View file

@ -1,5 +1,10 @@
import { buildCloneIndex, instanceAndClones } from '#core/kiwi/instance-overrides/clone-index'
import { applyComponentPropRef } from '#core/kiwi/instance-overrides/component-props/apply'
import { fallbackRefsForChild, findPropRefs, valueForRef } from '#core/kiwi/instance-overrides/component-props/refs'
import {
fallbackRefsForChild,
findPropRefs,
valueForRef
} from '#core/kiwi/instance-overrides/component-props/refs'
import { assignmentsToValueMap } from '#core/kiwi/instance-overrides/component-props/values'
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
import type {
@ -8,6 +13,7 @@ import type {
ComponentPropValue,
OverrideContext
} from '#core/kiwi/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
function applyChildPropRefs(
ctx: OverrideContext,
@ -23,6 +29,32 @@ function applyChildPropRefs(
}
}
function sourceChildPropRefs(
ctx: OverrideContext,
sourceParentId: string | null | undefined,
child: SceneNode,
propRefsMap: Map<string, ComponentPropRef[]>
): ComponentPropRef[] | undefined {
if (!sourceParentId) return undefined
const sourceParent = ctx.graph.getNode(sourceParentId)
if (!sourceParent) return undefined
let fallbackMatchId: string | undefined
for (const sourceChildId of sourceParent.childIds) {
const sourceChild = ctx.graph.getNode(sourceChildId)
if (!sourceChild) continue
if (sourceChild.componentId && sourceChild.componentId === child.componentId) {
const refs = findPropRefs(ctx, sourceChild.id, propRefsMap)
if (refs) return refs
}
if (!fallbackMatchId && sourceChild.name === child.name && sourceChild.type === child.type) {
fallbackMatchId = sourceChild.id
}
}
return fallbackMatchId ? findPropRefs(ctx, fallbackMatchId, propRefsMap) : undefined
}
function applyPropAssignments(
ctx: OverrideContext,
parentId: string,
@ -41,6 +73,7 @@ function applyPropAssignments(
}
const refs =
sourceChildPropRefs(ctx, parent.componentId, child, propRefsMap) ??
findPropRefs(ctx, child.componentId, propRefsMap) ??
fallbackRefsForChild(ctx, child.name, valueByDef)
applyChildPropRefs(ctx, childId, refs, valueByDef, modified)
@ -77,6 +110,8 @@ export function applyOverrideAssignments(
propRefsMap: Map<string, ComponentPropRef[]>,
modified: Set<string>
): void {
const clonesBySource = buildCloneIndex(ctx)
const clonesByInstance = new Map<string, string[]>()
for (const [figmaId, nc] of ctx.changeMap) {
const instanceNodeId = ctx.guidToNodeId.get(figmaId)
if (!instanceNodeId || (ctx.activeNodeIds && !ctx.activeNodeIds.has(instanceNodeId))) continue
@ -90,16 +125,17 @@ export function applyOverrideAssignments(
const guids = ov.guidPath?.guids
if (!guids?.length) continue
const targetId = resolveOverrideTarget(ctx, instanceNodeId, guids)
if (!targetId) continue
const valueByDef = assignmentsToValueMap(ctx, ov.componentPropAssignments, true)
for (const targetInstanceId of instanceAndClones(
instanceNodeId,
clonesBySource,
clonesByInstance
)) {
const targetId = resolveOverrideTarget(ctx, targetInstanceId, guids)
if (!targetId) continue
applyPropAssignments(
ctx,
targetId,
assignmentsToValueMap(ctx, ov.componentPropAssignments, true),
propRefsMap,
modified
)
applyPropAssignments(ctx, targetId, valueByDef, propRefsMap, modified)
}
}
}
}

View file

@ -1,7 +1,10 @@
import type {
ComponentPropRef,
ComponentPropValue,
OverrideContext
} from '#core/kiwi/instance-overrides/types'
import { guidToString } from '#core/kiwi/node-change/convert'
import type { ComponentPropRef, ComponentPropValue, OverrideContext } from '#core/kiwi/instance-overrides/types'
import { normalizePropName, stringToGuidParts } from './values'
export function findPropRefs(
@ -11,12 +14,15 @@ export function findPropRefs(
): ComponentPropRef[] | undefined {
let sourceId: string | undefined = nodeId
for (let depth = 0; sourceId && depth < 10; depth++) {
const figmaId = ctx.nodeIdToGuid.get(sourceId)
const node = ctx.graph.getNode(sourceId)
const overrideKey = node?.overrideKey
? (ctx.overrideKeyToGuid.get(node.overrideKey) ?? node.overrideKey)
: undefined
const figmaId = ctx.nodeIdToGuid.get(sourceId) ?? overrideKey
if (figmaId) {
const refs = propRefsMap.get(figmaId)
if (refs) return refs
}
const node = ctx.graph.getNode(sourceId)
const nextId = node?.componentId ?? undefined
if (nextId === sourceId) break
sourceId = nextId

View file

@ -400,6 +400,50 @@ describe('computeVisualBounds', () => {
expect(outsideBounds).toEqual({ minX: 9, minY: 19, maxX: 111, maxY: 71 })
})
test('nested clipping stops descendants outside the ancestor clip', () => {
const nodes = {
root: {
id: 'root',
type: 'FRAME',
width: 100,
height: 100,
visible: true,
clipsContent: true,
childIds: ['row']
},
row: {
id: 'row',
type: 'FRAME',
width: 100,
height: 50,
visible: true,
clipsContent: true,
childIds: ['cell']
},
cell: {
id: 'cell',
type: 'FRAME',
width: 50,
height: 50,
visible: true,
childIds: []
}
}
const positions: Record<keyof typeof nodes, Vector> = {
root: { x: 0, y: 0 },
row: { x: 0, y: 120 },
cell: { x: 0, y: 120 }
}
const bounds = computeDescendantVisualBounds(
['root'],
(id) => nodes[id as keyof typeof nodes],
(id) => positions[id as keyof typeof positions]
)
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 })
})
test('multiple effects accumulate directional overflow', () => {
const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
const multiEffect = computeVisualBounds(

View file

@ -39,8 +39,8 @@ describe('derived instance layout regressions', () => {
expect(inputFrame?.width).toBeCloseTo(375.7498, 3)
expect(inputFrame?.height).toBeCloseTo(39.3803, 3)
expect(content).toMatchObject({ x: 0, y: 0 })
expect(firstBadge?.x).toBeCloseTo(7.1268, 3)
expect(firstBadge?.y).toBeCloseTo(5.3451, 3)
expect(firstBadge?.x).toBeCloseTo(8, 3)
expect(firstBadge?.y).toBeCloseTo(6, 3)
expect(firstBadge?.width).toBeCloseTo(85.3239, 3)
expect(firstBadge?.height).toBeCloseTo(28.6901, 3)
expect(firstBadgeContent).toMatchObject({ x: 0, y: 0 })
@ -50,6 +50,31 @@ describe('derived instance layout regressions', () => {
expect(placeholderText?.y).toBeCloseTo(10.6901, 3)
})
test('propagates nested badge component property overrides through cloned instances', () => {
const input = previewChild(layoutGraph, layoutNodes, 'Input')
const inputRoot = childNamed(layoutGraph, input, '_input')
const inputFrame = childNamed(layoutGraph, inputRoot, 'Input')
const content = childNamed(layoutGraph, inputFrame, 'Content')
const tags = childNamed(layoutGraph, content, 'Tags')
const badges = tags
? layoutGraph.getChildren(tags.id).filter((node) => node.name === 'Badge')
: []
expect(badges).toHaveLength(3)
for (const badge of badges) {
const badgeContent = childNamed(layoutGraph, badge, '_badge-and-tag')
const avatar = childNamed(layoutGraph, badgeContent, 'Avatar')
const closeIcon = childNamed(layoutGraph, badgeContent, 'Close-Icon')
const avatarShape = avatar ? layoutGraph.getChildren(avatar.id)[0] : undefined
const closeGlyph = childNamed(layoutGraph, closeIcon, 'x')
expect(avatar?.visible).toBe(true)
expect(closeIcon?.visible).toBe(true)
expect(closeGlyph?.visible).toBe(true)
expect(avatarShape?.fills.some((fill) => fill.type === 'IMAGE' && fill.visible)).toBe(true)
}
})
test('does not collapse unrelated datepicker instances to the page origin', () => {
const datepicker = previewChild(layoutGraph, layoutNodes, '_datepicker')
expect(datepicker?.x).toBeCloseTo(765.2428, 3)
@ -78,6 +103,26 @@ describe('derived instance layout regressions', () => {
}
})
test('propagates static icon color overrides through checked-list clones', () => {
const title = previewChild(layoutGraph, layoutNodes, 'Title + Description')
const checkedList = childNamed(layoutGraph, title, 'Checked List')
const listItems = checkedList ? layoutGraph.getChildren(checkedList.id) : []
expect(listItems).toHaveLength(3)
for (const item of listItems) {
const list = childNamed(layoutGraph, item, '_list')
const inline = childNamed(layoutGraph, list, 'Inline')
const icon = childNamed(layoutGraph, inline, 'Static Icon')
const iconRoot = childNamed(layoutGraph, icon, '_icon-xs')
const check = childNamed(layoutGraph, iconRoot, 'check')
const vector = check ? layoutGraph.getChildren(check.id)[0] : undefined
const stroke = vector?.strokes[0]
expect(stroke?.visible).toBe(true)
expect(stroke?.color).toMatchObject({ r: 1, g: 1, b: 1, a: 1 })
}
})
test('preserves WYSIWYG toolbar padding', () => {
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
const toolbarRoot = childNamed(layoutGraph, wysiwyg, '_on-text-WYSIWYG-toolbar')
@ -145,9 +190,9 @@ describe('derived instance layout regressions', () => {
const logoBounds = logoGroup ? computeContentBounds(layoutGraph, [logoGroup.id]) : null
const logoAbs = logoGroup ? layoutGraph.getAbsolutePosition(logoGroup.id) : { x: 0, y: 0 }
const logo = logoGroup
expect(logoBounds?.minX).toBeLessThan(logoAbs.x)
expect(logoBounds?.minY).toBeLessThan(logoAbs.y)
expect(logoBounds?.maxX).toBeGreaterThan(logoAbs.x + (logo?.width ?? 0))
expect(logoBounds?.maxY).toBeGreaterThan(logoAbs.y + (logo?.height ?? 0))
expect(logoBounds?.minX).toBeLessThanOrEqual(logoAbs.x)
expect(logoBounds?.minY).toBeLessThanOrEqual(logoAbs.y)
expect(logoBounds?.maxX).toBeGreaterThanOrEqual(logoAbs.x + (logo?.width ?? 0))
expect(logoBounds?.maxY).toBeGreaterThanOrEqual(logoAbs.y + (logo?.height ?? 0))
})
})