From 7d0f9746ee0426c274c7458e7688b67f2312e76c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 12 Aug 2026 19:36:37 +0300 Subject: [PATCH 1/3] fix(fig): preserve nested text overrides - Propagate effective text through structurally protected clone chains - Preserve explicit text overrides and add focused regression coverage --- CHANGELOG.md | 1 + .../src/instance-overrides/sync/propagate.ts | 11 ++++++- packages/fig/tests/instance-overrides.test.ts | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b609a58f..e1217585d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Restore native copy, cut, and paste shortcuts in desktop text inputs while preserving design clipboard handling on the canvas. - Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance. - Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and keep the document language synchronized with the selected locale. +- Preserve effective nested instance text overrides when importing complex Figma component hierarchies. (#102) - Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452) ## 0.14.0 - 2026-08-10 diff --git a/packages/fig/src/instance-overrides/sync/propagate.ts b/packages/fig/src/instance-overrides/sync/propagate.ts index 027c35972..fe321cf5e 100644 --- a/packages/fig/src/instance-overrides/sync/propagate.ts +++ b/packages/fig/src/instance-overrides/sync/propagate.ts @@ -1,6 +1,6 @@ import type { SceneGraph } from '@open-pencil/scene-graph' -import type { ProtectionMap } from '../patches' +import { isFieldProtected, type ProtectionMap } from '../patches' import { buildClonesMap, syncChildrenDeep } from './clones' import { syncNodeProps } from './fields' import { indexCloneSubtree, remapRepopulatedChildSources, snapshotChildSources } from './sources' @@ -129,6 +129,15 @@ export function propagateOverridesTransitively( if (!node) continue if (skip.has(cloneId)) { + // A directly overridden clone may still inherit effective text from an + // overridden source. Respect its own text override when present. + if ( + source.type === 'TEXT' && + node.type === 'TEXT' && + !isFieldProtected(protections, node.id, 'text') + ) { + graph.updateNode(node.id, { text: source.text }) + } syncQueue.push(cloneId) continue } diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index 779edb900..61655a01c 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test' import { SceneGraph } from '@open-pencil/scene-graph' +import { propagateOverridesTransitively } from '../src/instance-overrides/sync/propagate' + import { populateAndApplyOverrides, protectField, @@ -407,6 +409,33 @@ describe('@open-pencil/fig instance interpretation', () => { expect(graph.getNode(target.id)?.boundVariables).toEqual({ width: 'width-var' }) }) + test('inherits effective text on a structurally protected clone', () => { + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + const component = graph.createNode('COMPONENT', pageId) + const source = graph.createNode('TEXT', component.id, { + text: 'Effective label' + }) + const instance = graph.createNode('INSTANCE', pageId, { componentId: component.id }) + graph.populateInstanceChildren(instance.id, component.id, 'fig-import') + const clone = graph.getChildren(instance.id)[0] + graph.updateNode(clone.id, { text: 'Default label' }) + const protections: ProtectionMap = new Map() + protectField(protections, clone.id, 'width') + + propagateOverridesTransitively( + graph, + new Set([source.id, clone.id]), + new Set(), + new Map(), + undefined, + undefined, + protections + ) + + expect(graph.getNode(clone.id)?.text).toBe('Effective label') + }) + test('preserves protected text while synchronizing other fields', () => { const graph = new SceneGraph() const pageId = graph.getPages()[0].id From 38e98095b23a070fa2f0ea1166d1a5da5d6e2071 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 12 Aug 2026 19:44:32 +0300 Subject: [PATCH 2/3] chore(fig): format instance override test --- packages/fig/tests/instance-overrides.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index 61655a01c..015ca2480 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -2,14 +2,13 @@ import { describe, expect, test } from 'bun:test' import { SceneGraph } from '@open-pencil/scene-graph' -import { propagateOverridesTransitively } from '../src/instance-overrides/sync/propagate' - import { populateAndApplyOverrides, protectField, syncNodeProps, type ProtectionMap } from '../src/instance-overrides' +import { propagateOverridesTransitively } from '../src/instance-overrides/sync/propagate' describe('@open-pencil/fig instance interpretation', () => { test('populates an empty instance from its component tree', () => { From cf72b8d1c4e71d450a515d446b700756e1bbe9c3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 12 Aug 2026 20:08:06 +0300 Subject: [PATCH 3/3] fix(clipboard): preserve pasted instance links - Share exported GUID assignments across clipboard roots - Preserve source metadata while applying pasted instance overrides --- packages/core/src/clipboard.ts | 14 +++++++--- tests/engine/clipboard/figma/html.test.ts | 31 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 97c9f9e37..e16acc0bb 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -8,7 +8,7 @@ import { sortChildren } from '@open-pencil/fig/node-change' import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec' -import type { NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec' +import type { GUID, NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec' import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' @@ -283,7 +283,9 @@ export function importClipboardNodes( remapComponentIds(created, graph) - populateAndApplyOverrides(graph, guidMap as Map, created, blobs) + graph.preserveSourceMetadataDuring(() => { + populateAndApplyOverrides(graph, guidMap as Map, created, blobs) + }) for (const figmaId of internalTopLevel) { const ourId = created.get(figmaId) @@ -321,6 +323,8 @@ export async function buildFigmaClipboardHTML( } } + const nodeIdToGuid = new Map() + const assignedGuidValues = new Set() const blobs: Uint8Array[] = [] for (let i = 0; i < nodes.length; i++) { collectTextNodes(nodes[i]) @@ -332,8 +336,12 @@ export async function buildFigmaClipboardHTML( localIdCounter, graph, blobs, + nodeIdToGuid, + fontDigestMap, undefined, - fontDigestMap + undefined, + undefined, + assignedGuidValues ) ) } diff --git a/tests/engine/clipboard/figma/html.test.ts b/tests/engine/clipboard/figma/html.test.ts index f8625026d..5800b52ff 100644 --- a/tests/engine/clipboard/figma/html.test.ts +++ b/tests/engine/clipboard/figma/html.test.ts @@ -141,6 +141,37 @@ describe('buildFigmaClipboardHTML', () => { expect(html).toContain('figmeta') }) + it('preserves source metadata while importing instance overrides', async () => { + const source = new SceneGraph() + const sourcePage = source.getPages()[0] + const component = source.createNode('COMPONENT', sourcePage.id, { name: 'Button' }) + source.createNode('TEXT', component.id, { name: 'Label', text: 'Effective label' }) + const instance = source.createNode('INSTANCE', sourcePage.id, { + name: 'Button instance', + componentId: component.id + }) + source.populateInstanceChildren(instance.id, component.id) + + const html = await buildFigmaClipboardHTML([component, instance], source) + const parsed = await parseFigmaClipboard(expectDefined(html, 'Figma clipboard html')) + const clipboard = expectDefined(parsed, 'Figma clipboard') + const target = new SceneGraph() + const targetPage = target.getPages()[0] + importClipboardNodes(clipboard.nodes, target, targetPage.id) + const importedInstance = [...target.getAllNodes()].find( + (node) => node.type === 'INSTANCE' && node.name === 'Button instance' + ) + const importedLabel = importedInstance + ? [...target.getAllNodes()].find( + (node) => node.type === 'TEXT' && node.parentId === importedInstance.id + ) + : undefined + + expect(importedInstance).toBeDefined() + expect(importedLabel?.text).toBe('Effective label') + expect(importedLabel?.source.editedFields).toEqual([]) + }) + it('roundtrips: encode then decode back', async () => { const graph = new SceneGraph() const page = graph.getPages()[0]