From 82fc5fb7bb405821ca1b97a7112aa9da3e08039e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 12:29:51 +0300 Subject: [PATCH] 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 --- packages/core/src/canvas/scene.ts | 2 +- packages/core/src/geometry.ts | 1 + .../kiwi/instance-overrides/clone-index.ts | 33 +++++++++++ .../component-props/assignments.ts | 56 ++++++++++++++---- .../component-props/refs.ts | 14 +++-- tests/engine/geometry/visual-bounds.test.ts | 44 ++++++++++++++ .../fig/import/instance-regressions.test.ts | 57 +++++++++++++++++-- 7 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/kiwi/instance-overrides/clone-index.ts diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 1296aef9e..e80a71763 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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 diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts index 65bca22a4..c3823ada0 100644 --- a/packages/core/src/geometry.ts +++ b/packages/core/src/geometry.ts @@ -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 ?? []) { diff --git a/packages/core/src/kiwi/instance-overrides/clone-index.ts b/packages/core/src/kiwi/instance-overrides/clone-index.ts new file mode 100644 index 000000000..17c25edf9 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/clone-index.ts @@ -0,0 +1,33 @@ +import type { OverrideContext } from './types' + +export function buildCloneIndex(ctx: OverrideContext): Map { + const clonesBySource = new Map() + 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, + cache: Map +): string[] { + const cached = cache.get(instanceNodeId) + if (cached) return cached + const result: string[] = [] + const seen = new Set() + 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 +} diff --git a/packages/core/src/kiwi/instance-overrides/component-props/assignments.ts b/packages/core/src/kiwi/instance-overrides/component-props/assignments.ts index 91147a9d1..337ab1c4f 100644 --- a/packages/core/src/kiwi/instance-overrides/component-props/assignments.ts +++ b/packages/core/src/kiwi/instance-overrides/component-props/assignments.ts @@ -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 +): 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, modified: Set ): void { + const clonesBySource = buildCloneIndex(ctx) + const clonesByInstance = new Map() 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) + } } } } diff --git a/packages/core/src/kiwi/instance-overrides/component-props/refs.ts b/packages/core/src/kiwi/instance-overrides/component-props/refs.ts index 115a4b58d..875e487d5 100644 --- a/packages/core/src/kiwi/instance-overrides/component-props/refs.ts +++ b/packages/core/src/kiwi/instance-overrides/component-props/refs.ts @@ -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 diff --git a/tests/engine/geometry/visual-bounds.test.ts b/tests/engine/geometry/visual-bounds.test.ts index 5d8283bee..71390ea94 100644 --- a/tests/engine/geometry/visual-bounds.test.ts +++ b/tests/engine/geometry/visual-bounds.test.ts @@ -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 = { + 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( diff --git a/tests/engine/io/fig/import/instance-regressions.test.ts b/tests/engine/io/fig/import/instance-regressions.test.ts index b6b98780d..803244f7a 100644 --- a/tests/engine/io/fig/import/instance-regressions.test.ts +++ b/tests/engine/io/fig/import/instance-regressions.test.ts @@ -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)) }) })