From ff54a222d8bc1d13a9704c9c8291582bc1b74f9b Mon Sep 17 00:00:00 2001 From: xemc <0xemc@protonmail.com> Date: Tue, 18 Aug 2026 11:22:29 +1000 Subject: [PATCH 1/9] fix(fig): live edits to instance-descendant fields discarded on save Editing a field on a node nested inside an INSTANCE (e.g. recoloring an icon instance's fill) was silently reverted on the next save/reload. Nothing recorded the edit as an override, and the lazy-population resync that runs on every export (applySymbolOverrides, propagateResolvedFills) unconditionally reapplied the stale, originally-imported override data over the live graph. Fix records instance-descendant edits via recordInstanceOverride (wired into the shared FigmaNodeProxy updateNode helper) and has both resync passes skip fields with a live override via a new hasLiveOverride check. Also adds serializeFillOverrides so fill overrides are actually written to the exported symbolOverrides payload (previously only :text overrides were ever serialized). --- packages/core/src/figma-api/accessor-utils.ts | 6 +- packages/fig/src/instance-overrides/index.ts | 2 + .../src/instance-overrides/live-overrides.ts | 16 +++++ .../instance-overrides/symbol/overrides.ts | 19 ++++++ packages/fig/src/node-change/export-node.ts | 67 ++++++++++++++----- packages/scene-graph/src/index.ts | 7 +- packages/scene-graph/src/instances.ts | 36 +++++++++- .../api/create/instance-fill-override.test.ts | 31 +++++++++ .../fig/instance-fill-override-resave.test.ts | 61 +++++++++++++++++ .../instance-fill-override-roundtrip.test.ts | 44 ++++++++++++ 10 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 packages/fig/src/instance-overrides/live-overrides.ts create mode 100644 tests/engine/figma/api/create/instance-fill-override.test.ts create mode 100644 tests/engine/io/fig/instance-fill-override-resave.test.ts create mode 100644 tests/engine/io/fig/instance-fill-override-roundtrip.test.ts diff --git a/packages/core/src/figma-api/accessor-utils.ts b/packages/core/src/figma-api/accessor-utils.ts index e07a0264b..30a79841a 100644 --- a/packages/core/src/figma-api/accessor-utils.ts +++ b/packages/core/src/figma-api/accessor-utils.ts @@ -1,3 +1,4 @@ +import { recordInstanceOverride } from '@open-pencil/scene-graph' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import { assertNodeEditable } from '#core/editor/capabilities' @@ -35,5 +36,8 @@ export function updateNode( changes: Partial ): void { assertProxyEditable(target, internals) - graph(target, internals).updateNode(nodeId(target, internals), changes) + const g = graph(target, internals) + const id = nodeId(target, internals) + g.updateNode(id, changes) + recordInstanceOverride(g, id, Object.keys(changes)) } diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index b6c49cfab..70c262894 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -33,6 +33,7 @@ import { applyGeneratedFreeformStretch, reconcileEffectiveCloneGeometry } from './derived-symbol-data/propagate' +import { hasLiveOverride } from './live-overrides' import { populateInstances } from './populate' import { preComputeRoots } from './resolve' import { applySymbolOverrides } from './symbol/overrides' @@ -129,6 +130,7 @@ function propagateResolvedFills( const source = graph.getNode(node.componentId) if (!source || isEqual(source.fills, node.fills)) continue if (protectedNodes.has(node.id) && !protectedNodes.has(source.id)) continue + if (hasLiveOverride(graph, node.id, 'fills')) continue graph.updateNode(node.id, { fills: copyFills(source.fills) }) changed = true } diff --git a/packages/fig/src/instance-overrides/live-overrides.ts b/packages/fig/src/instance-overrides/live-overrides.ts new file mode 100644 index 000000000..a048922a8 --- /dev/null +++ b/packages/fig/src/instance-overrides/live-overrides.ts @@ -0,0 +1,16 @@ +import { findInstanceAncestor } from '@open-pencil/scene-graph' +import type { SceneGraph } from '@open-pencil/scene-graph' + +/** + * True when `nodeId`'s `field` was recorded as a live instance override + * (via recordInstanceOverride) — i.e. the field was edited after the file + * was last imported/saved. Lazy-population resync (re-cloning instance + * children, reapplying the stored symbol overrides) must not clobber these, + * since the stored data predates the edit. + */ +export function hasLiveOverride(graph: SceneGraph, nodeId: string, field: string): boolean { + const instance = findInstanceAncestor(graph, nodeId) + if (!instance) return false + const key = nodeId === instance.id ? field : `${nodeId}:${field}` + return key in instance.overrides +} diff --git a/packages/fig/src/instance-overrides/symbol/overrides.ts b/packages/fig/src/instance-overrides/symbol/overrides.ts index 6f2e5de88..cb9f6d56a 100644 --- a/packages/fig/src/instance-overrides/symbol/overrides.ts +++ b/packages/fig/src/instance-overrides/symbol/overrides.ts @@ -1,8 +1,26 @@ +import { hasLiveOverride } from '../live-overrides' import { applyOverridePatch } from '../patches' import { resolveOverrideTarget } from '../resolve' import type { OverrideContext } from '../types' import { patchFromSymbolOverride } from './patches' +/** + * Strips fields from a stored-override patch that were since live-edited + * (recordInstanceOverride) — reapplying the stale stored value on every + * lazy-population pass would otherwise silently discard the edit. + */ +function dropLiveOverriddenFields( + ctx: OverrideContext, + targetId: string, + patch: ReturnType +): void { + if (!patch?.props) return + const kept = Object.fromEntries( + Object.entries(patch.props).filter(([field]) => !hasLiveOverride(ctx.graph, targetId, field)) + ) + patch.props = Object.keys(kept).length > 0 ? (kept as typeof patch.props) : undefined +} + function isActiveInstance(ctx: OverrideContext, nodeId: string | undefined): nodeId is string { return nodeId !== undefined && (!ctx.activeNodeIds || ctx.activeNodeIds.has(nodeId)) } @@ -51,6 +69,7 @@ export function applySymbolOverrides(ctx: OverrideContext, propertiesOnly = fals const patch = patchFromSymbolOverride(ctx, targetId, ov) if (!patch) continue + dropLiveOverriddenFields(ctx, targetId, patch) preserveInstanceRootBounds(nc.size !== undefined, nodeId, targetId, patch) if (propertiesOnly) patch.swapComponentId = undefined if (!patch.swapComponentId && !patch.props) continue diff --git a/packages/fig/src/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts index 1596b9547..58aa52030 100644 --- a/packages/fig/src/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -29,6 +29,7 @@ type KiwiBooleanOperation = NonNullable interface KiwiSymbolOverridePayload { guidPath?: { guids?: GUID[] } textData?: { characters?: string } + fillPaints?: Paint[] [key: string]: unknown } @@ -372,11 +373,7 @@ function serializeTextOverrides( const target = context.graph.getNode(targetId) if (!target || !isDescendantOf(context, targetId, instance.id)) continue - const sourceId = target.componentId - if (!sourceId) continue - const source = context.graph.getNode(sourceId) - const overrideGuid = source?.overrideKey ? parseGuidOrNull(source.overrideKey) : null - const targetGuid = overrideGuid ?? getOrCreateNodeGuid(context, sourceId, localIdCounter) + const targetGuid = resolveOverrideTargetGuid(context, target, localIdCounter) if (!targetGuid) continue result.push({ @@ -387,6 +384,46 @@ function serializeTextOverrides( return result } +/** + * Resolves the descendant-node identity a symbol override applies to: the + * GUID of the corresponding node inside the target's own main component + * (cloneNodeProps stamps componentId with that node's id on every clone). + */ +function resolveOverrideTargetGuid( + context: SceneNodeToKiwiContext, + target: SceneNode, + localIdCounter: { value: number } +): GUID | undefined { + const sourceId = target.componentId + if (!sourceId) return undefined + const source = context.graph.getNode(sourceId) + const overrideGuid = source?.overrideKey ? parseGuidOrNull(source.overrideKey) : null + return overrideGuid ?? getOrCreateNodeGuid(context, sourceId, localIdCounter) +} + +function serializeFillOverrides( + context: SceneNodeToKiwiContext, + instance: SceneNode, + localIdCounter: { value: number } +): KiwiSymbolOverridePayload[] { + const result: KiwiSymbolOverridePayload[] = [] + for (const key of Object.keys(instance.overrides)) { + if (!key.endsWith(':fills')) continue + const targetId = key.slice(0, -':fills'.length) + const target = context.graph.getNode(targetId) + if (!target || !isDescendantOf(context, targetId, instance.id)) continue + + const targetGuid = resolveOverrideTargetGuid(context, target, localIdCounter) + if (!targetGuid) continue + + result.push({ + guidPath: { guids: [targetGuid] }, + fillPaints: target.fills.map((fill) => context.fillToKiwiPaint(fill)) + }) + } + return result +} + function overridePathKey(payload: KiwiSymbolOverridePayload): string | null { const guids = payload.guidPath?.guids return guids?.length @@ -394,12 +431,12 @@ function overridePathKey(payload: KiwiSymbolOverridePayload): string | null { : null } -function mergeTextOverrides( +function mergeOverrides( symbolOverrides: KiwiSymbolOverridePayload[], - textOverrides: KiwiSymbolOverridePayload[] + newOverrides: KiwiSymbolOverridePayload[] ): void { - for (const textOverride of textOverrides) { - const pathKey = overridePathKey(textOverride) + for (const override of newOverrides) { + const pathKey = overridePathKey(override) let existingIndex = -1 if (pathKey) { for (let index = symbolOverrides.length - 1; index >= 0; index--) { @@ -408,13 +445,8 @@ function mergeTextOverrides( break } } - if (existingIndex < 0) symbolOverrides.push(textOverride) - else { - symbolOverrides[existingIndex] = { - ...symbolOverrides[existingIndex], - textData: textOverride.textData - } - } + if (existingIndex < 0) symbolOverrides.push(override) + else symbolOverrides[existingIndex] = { ...symbolOverrides[existingIndex], ...override } } } @@ -589,7 +621,8 @@ function applyInstancePayload( }) as KiwiSymbolOverridePayload[]) ) } - mergeTextOverrides(symbolOverrides, serializeTextOverrides(context, node, localIdCounter)) + mergeOverrides(symbolOverrides, serializeTextOverrides(context, node, localIdCounter)) + mergeOverrides(symbolOverrides, serializeFillOverrides(context, node, localIdCounter)) if (symbolOverrides.length > 0) symbolData.symbolOverrides = symbolOverrides if (node.source.fig.uniformScaleFactor != null) { symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index d9e6f97e0..4265de387 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -1,7 +1,12 @@ /* eslint-disable max-lines -- SceneGraph exposes a stable facade over domain modules */ export * from './images' export * from './copy' -export { copyInstanceComponentProps } from './instances' +export { + copyInstanceComponentProps, + findInstanceAncestor, + INSTANCE_SYNC_PROPS, + recordInstanceOverride +} from './instances' export * from './snap' export * from './export-scale' export * from './coordinate' diff --git a/packages/scene-graph/src/instances.ts b/packages/scene-graph/src/instances.ts index 988e9db04..4d37ebfa1 100644 --- a/packages/scene-graph/src/instances.ts +++ b/packages/scene-graph/src/instances.ts @@ -4,7 +4,7 @@ import type { NodeCloneMode } from './copy' export type { NodeCloneMode } from './copy' -const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ +export const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ 'width', 'height', 'minWidth', @@ -284,3 +284,37 @@ export function getInstances(graph: SceneGraph, componentId: string): SceneNode[ } return instances } + +/** Nearest INSTANCE at or above `nodeId` — self, parent, grandparent, etc. */ +export function findInstanceAncestor(graph: SceneGraph, nodeId: string): SceneNode | undefined { + let current = graph.nodes.get(nodeId) + while (current) { + if (current.type === 'INSTANCE') return current + current = current.parentId ? graph.nodes.get(current.parentId) : undefined + } + return undefined +} + +/** + * Marks `nodeId`'s `fields` as instance overrides so resync (swapInstanceComponent, + * syncInstances) won't clobber them, and — if `nodeId` sits inside an INSTANCE — + * so the .fig exporter knows to write the diff out as a symbol override. A no-op + * for fields outside INSTANCE_SYNC_PROPS or nodes with no INSTANCE ancestor. + */ +export function recordInstanceOverride( + graph: SceneGraph, + nodeId: string, + fields: Iterable +): void { + const instance = findInstanceAncestor(graph, nodeId) + if (!instance) return + + const relevant = [...fields].filter((field) => (INSTANCE_SYNC_PROPS as string[]).includes(field)) + if (relevant.length === 0) return + + const overrides = { ...instance.overrides } + for (const field of relevant) { + overrides[nodeId === instance.id ? field : `${nodeId}:${field}`] = true + } + graph.updateNode(instance.id, { overrides }) +} diff --git a/tests/engine/figma/api/create/instance-fill-override.test.ts b/tests/engine/figma/api/create/instance-fill-override.test.ts new file mode 100644 index 000000000..8d4e51422 --- /dev/null +++ b/tests/engine/figma/api/create/instance-fill-override.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from '../helpers' + +describe('setting fills on an instance descendant', () => { + test('records an instance override so the change survives resync', () => { + const api = createAPI() + const icon = api.createComponent() + icon.resize(16, 16) + + const instance = icon.createInstance() + const vector = api.graph.createNode('VECTOR', instance.id, { width: 16, height: 16 }) + api.graph.updateNode(vector.id, { componentId: icon.id }) + + const vectorProxy = api.getNodeById(vector.id) + if (!vectorProxy) throw new Error('vector proxy not found') + vectorProxy.fills = [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }] + + const raw = api.graph.getNode(instance.id) + expect(raw?.overrides[`${vector.id}:fills`]).toBe(true) + }) + + test('does not record an override for a node with no instance ancestor', () => { + const api = createAPI() + const frame = api.createFrame() + frame.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + + const raw = api.graph.getNode(frame.id) + expect(raw?.overrides ?? {}).toEqual({}) + }) +}) diff --git a/tests/engine/io/fig/instance-fill-override-resave.test.ts b/tests/engine/io/fig/instance-fill-override-resave.test.ts new file mode 100644 index 000000000..54154410b --- /dev/null +++ b/tests/engine/io/fig/instance-fill-override-resave.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test' + +import { FigmaAPI } from '@open-pencil/core' +import { exportFigFile, parseFigFile } from '@open-pencil/core/io' +import { initCodec } from '@open-pencil/core/kiwi' +import { SceneGraph } from '@open-pencil/scene-graph' + +const BLUE = { r: 0, g: 0, b: 1, a: 1 } +const RED = { r: 1, g: 0, b: 0, a: 1 } + +function findInstanceAndVector(graph: SceneGraph) { + const instance = graph.getAllNodes().find((n) => n.type === 'INSTANCE') + if (!instance) throw new Error('instance not found') + const vector = graph.getChildren(instance.id)[0] + return { instance, vector } +} + +describe('re-editing an instance fill across multiple save/reload cycles', () => { + test('a second live edit overrides the first, not the other way around', async () => { + await initCodec() + + const graph = new SceneGraph() + const page = graph.getPages()[0] + const icon = graph.createNode('COMPONENT', page.id, { name: 'Icon' }) + graph.createNode('VECTOR', icon.id, { + name: 'path', + width: 16, + height: 16, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + + const instance = graph.createInstance(icon.id, page.id) + if (!instance) throw new Error('failed to create instance') + const figma1 = new FigmaAPI(graph) + const vectorProxy1 = figma1.getNodeById(graph.getChildren(instance.id)[0].id) + if (!vectorProxy1) throw new Error('vector proxy not found') + vectorProxy1.fills = [{ type: 'SOLID', color: BLUE, opacity: 1, visible: true }] + + // Round 1: export the blue edit, reimport it — this is the state a + // freshly-reopened file would be in, with `blue` baked into the stored + // symbolOverride but no live `overrides` bookkeeping (per §1 of the fix). + const bytes1 = await exportFigFile(graph) + const graph2 = await parseFigFile(bytes1.buffer as ArrayBuffer) + const { vector: vector2 } = findInstanceAndVector(graph2) + expect(vector2.fills[0]?.color).toEqual(BLUE) + + // Round 2: live-edit the reimported instance's icon to red, then export + // and reimport again. Without the fix, `applySymbolOverrides` reapplies + // the STALE stored (blue) override on this same export pass, silently + // discarding the fresh (red) edit. + const figma2 = new FigmaAPI(graph2) + const vectorProxy2 = figma2.getNodeById(vector2.id) + if (!vectorProxy2) throw new Error('vector proxy not found') + vectorProxy2.fills = [{ type: 'SOLID', color: RED, opacity: 1, visible: true }] + + const bytes2 = await exportFigFile(graph2) + const graph3 = await parseFigFile(bytes2.buffer as ArrayBuffer) + const { vector: vector3 } = findInstanceAndVector(graph3) + expect(vector3.fills[0]?.color).toEqual(RED) + }) +}) diff --git a/tests/engine/io/fig/instance-fill-override-roundtrip.test.ts b/tests/engine/io/fig/instance-fill-override-roundtrip.test.ts new file mode 100644 index 000000000..2b74b175c --- /dev/null +++ b/tests/engine/io/fig/instance-fill-override-roundtrip.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test' + +import { exportFigFile, parseFigFile } from '@open-pencil/core/io' +import { initCodec } from '@open-pencil/core/kiwi' +import { SceneGraph } from '@open-pencil/scene-graph' + +describe('instance descendant fill override round trip', () => { + test('a recolored nested instance child keeps its color after a save/reload cycle', async () => { + await initCodec() + + const graph = new SceneGraph() + const page = graph.getPages()[0] + const icon = graph.createNode('COMPONENT', page.id, { name: 'Icon' }) + const path = graph.createNode('VECTOR', icon.id, { + name: 'path', + width: 16, + height: 16, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + + const instance = graph.createInstance(icon.id, page.id) + if (!instance) throw new Error('failed to create instance') + const instancePath = graph.getChildren(instance.id)[0] + expect(instancePath.componentId).toBe(path.id) + + graph.updateNode(instancePath.id, { + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + const { recordInstanceOverride } = await import('@open-pencil/scene-graph') + recordInstanceOverride(graph, instancePath.id, ['fills']) + + const exported = await exportFigFile(graph) + const reloaded = await parseFigFile(exported.buffer as ArrayBuffer) + + const reloadedInstance = reloaded.getAllNodes().find((n) => n.type === 'INSTANCE') + expect(reloadedInstance).toBeDefined() + const reloadedPath = reloadedInstance ? reloaded.getChildren(reloadedInstance.id)[0] : undefined + expect(reloadedPath?.fills[0]?.color).toEqual({ r: 0, g: 0, b: 1, a: 1 }) + + const reloadedIcon = reloaded.getAllNodes().find((n) => n.type === 'COMPONENT' && n.name === 'Icon') + const reloadedIconPath = reloadedIcon ? reloaded.getChildren(reloadedIcon.id)[0] : undefined + expect(reloadedIconPath?.fills[0]?.color).toEqual({ r: 0, g: 0, b: 0, a: 1 }) + }) +}) From 7e325e40726ebb6fb599d2f479633ed84d2b9071 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 23:40:59 +0300 Subject: [PATCH 2/9] fix(scene-graph): share instance sync fields --- packages/fig/src/instance-overrides/index.ts | 8 +++-- packages/scene-graph/src/index.ts | 5 ++-- packages/scene-graph/src/instances.ts | 31 ++++++++++---------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index 70c262894..1b17531cc 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -18,6 +18,11 @@ import { isEqual } from 'es-toolkit/predicate' import { guidToString, resolvedNumericBindingUpdate } from '@open-pencil/fig/node-change' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { + INSTANCE_SYNC_PROPS, + INSTANCE_SYNC_TEXT_PROPS, + hasInstanceOverride +} from '@open-pencil/scene-graph' import { copyFills, copyStyleRuns, @@ -33,7 +38,6 @@ import { applyGeneratedFreeformStretch, reconcileEffectiveCloneGeometry } from './derived-symbol-data/propagate' -import { hasLiveOverride } from './live-overrides' import { populateInstances } from './populate' import { preComputeRoots } from './resolve' import { applySymbolOverrides } from './symbol/overrides' @@ -130,7 +134,7 @@ function propagateResolvedFills( const source = graph.getNode(node.componentId) if (!source || isEqual(source.fills, node.fills)) continue if (protectedNodes.has(node.id) && !protectedNodes.has(source.id)) continue - if (hasLiveOverride(graph, node.id, 'fills')) continue + if (hasInstanceOverride(graph, node.id, 'fills')) continue graph.updateNode(node.id, { fills: copyFills(source.fills) }) changed = true } diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 94534e3ad..42aea1d09 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -4,8 +4,9 @@ export * from './components/properties' export * from './copy' export { copyInstanceComponentProps, - findInstanceAncestor, - INSTANCE_SYNC_PROPS, + hasInstanceOverride, + INSTANCE_SYNC_FIELDS, + INSTANCE_SYNC_TEXT_PROPS, recordInstanceOverride } from './instances' export * from './snap' diff --git a/packages/scene-graph/src/instances.ts b/packages/scene-graph/src/instances.ts index 4d37ebfa1..b50a31386 100644 --- a/packages/scene-graph/src/instances.ts +++ b/packages/scene-graph/src/instances.ts @@ -4,6 +4,15 @@ import type { NodeCloneMode } from './copy' export type { NodeCloneMode } from './copy' +export const INSTANCE_SYNC_TEXT_PROPS = [ + 'name', + 'text', + 'fontSize', + 'fontWeight', + 'fontFamily', + 'textDirection' +] as const + export const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ 'width', 'height', @@ -49,6 +58,8 @@ export const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ 'variableModes' ] +export const INSTANCE_SYNC_FIELDS = [...INSTANCE_SYNC_PROPS, ...INSTANCE_SYNC_TEXT_PROPS] as const + function setSceneProp( target: Partial, key: K, @@ -142,20 +153,7 @@ function syncChildren( const instChild = instChildMap.get(compChildId) if (!compChild || !instChild) continue - for (const key of INSTANCE_SYNC_PROPS) { - const overrideKey = `${instChild.id}:${key}` - if (overrideKey in overrides) continue - copyProp(instChild, compChild, key) - } - - for (const key of [ - 'name', - 'text', - 'fontSize', - 'fontWeight', - 'fontFamily', - 'textDirection' - ] as const) { + for (const key of INSTANCE_SYNC_FIELDS) { const overrideKey = `${instChild.id}:${key}` if (overrideKey in overrides) continue copyProp(instChild, compChild, key) @@ -309,7 +307,10 @@ export function recordInstanceOverride( const instance = findInstanceAncestor(graph, nodeId) if (!instance) return - const relevant = [...fields].filter((field) => (INSTANCE_SYNC_PROPS as string[]).includes(field)) + const relevant = [...fields].filter((field) => + (INSTANCE_SYNC_FIELDS as readonly string[]).includes(field) + ) + if (relevant.length === 0) return const overrides = { ...instance.overrides } From 01353e7ca7645365ff0cd45aca078317842e9e65 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 23:52:01 +0300 Subject: [PATCH 3/9] fix(scene-graph): preserve text instance overrides --- packages/core/src/figma-api/proxy.ts | 6 +++++- .../api/create/instance-fill-override.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 81b53f5da..97b031044 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -1,3 +1,4 @@ +import { recordInstanceOverride } from '@open-pencil/scene-graph' import type { SceneGraph, SceneNode, @@ -137,7 +138,10 @@ export class FigmaNodeProxy { private _update(changes: Partial): void { assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID]) - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], changes) + const graph = this[INTERNAL_GRAPH] + const id = this[INTERNAL_ID] + graph.updateNode(id, changes) + recordInstanceOverride(graph, id, Object.keys(changes)) } private _raw(): SceneNode { diff --git a/tests/engine/figma/api/create/instance-fill-override.test.ts b/tests/engine/figma/api/create/instance-fill-override.test.ts index 8d4e51422..aa24a70f3 100644 --- a/tests/engine/figma/api/create/instance-fill-override.test.ts +++ b/tests/engine/figma/api/create/instance-fill-override.test.ts @@ -20,6 +20,24 @@ describe('setting fills on an instance descendant', () => { expect(raw?.overrides[`${vector.id}:fills`]).toBe(true) }) + test('preserves text edits on an instance descendant during resync', () => { + const api = createAPI() + const component = api.createComponent() + const text = api.createText() + text.characters = 'Default' + component.appendChild(text) + + const instance = component.createInstance() + const instanceText = instance.children[0] + if (!instanceText) throw new Error('instance text not found') + instanceText.characters = 'Custom' + + expect(api.graph.getNode(instance.id)?.overrides[`${instanceText.id}:text`]).toBe(true) + text.characters = 'Updated default' + api.graph.syncInstances(component.id) + + expect(instanceText.characters).toBe('Custom') + }) test('does not record an override for a node with no instance ancestor', () => { const api = createAPI() const frame = api.createFrame() From f1ae82641205398acd03f103ae6b5adf38dad5d4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 22:55:15 +0300 Subject: [PATCH 4/9] fix(fig): record applied instance override fields --- packages/core/src/figma-api/accessor-utils.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/figma-api/accessor-utils.ts b/packages/core/src/figma-api/accessor-utils.ts index 30a79841a..101e99f77 100644 --- a/packages/core/src/figma-api/accessor-utils.ts +++ b/packages/core/src/figma-api/accessor-utils.ts @@ -38,6 +38,12 @@ export function updateNode( assertProxyEditable(target, internals) const g = graph(target, internals) const id = nodeId(target, internals) - g.updateNode(id, changes) - recordInstanceOverride(g, id, Object.keys(changes)) + const applied = Object.fromEntries( + Object.keys(changes) + .filter((key) => Reflect.get(changes, key) !== undefined) + .map((key) => [key, Reflect.get(changes, key)]) + ) as Partial + if (Object.keys(applied).length === 0) return + g.updateNode(id, applied) + recordInstanceOverride(g, id, Object.keys(applied)) } From 29aba9e95328e2b5d0fd3d099ceb84516c2d0f11 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 31 Aug 2026 23:18:00 +0300 Subject: [PATCH 5/9] fix(fig): restore instance constraint propagation --- packages/fig/src/instance-overrides/index.ts | 1 + .../fig/src/instance-overrides/live-overrides.ts | 16 ---------------- .../src/instance-overrides/symbol/overrides.ts | 7 +++++-- packages/scene-graph/src/index.ts | 3 +-- packages/scene-graph/src/instances.ts | 11 ++++++++++- 5 files changed, 17 insertions(+), 21 deletions(-) delete mode 100644 packages/fig/src/instance-overrides/live-overrides.ts diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index 1b17531cc..eb264976b 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -18,6 +18,7 @@ import { isEqual } from 'es-toolkit/predicate' import { guidToString, resolvedNumericBindingUpdate } from '@open-pencil/fig/node-change' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { hasInstanceOverride } from '@open-pencil/scene-graph' import { INSTANCE_SYNC_PROPS, INSTANCE_SYNC_TEXT_PROPS, diff --git a/packages/fig/src/instance-overrides/live-overrides.ts b/packages/fig/src/instance-overrides/live-overrides.ts deleted file mode 100644 index a048922a8..000000000 --- a/packages/fig/src/instance-overrides/live-overrides.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { findInstanceAncestor } from '@open-pencil/scene-graph' -import type { SceneGraph } from '@open-pencil/scene-graph' - -/** - * True when `nodeId`'s `field` was recorded as a live instance override - * (via recordInstanceOverride) — i.e. the field was edited after the file - * was last imported/saved. Lazy-population resync (re-cloning instance - * children, reapplying the stored symbol overrides) must not clobber these, - * since the stored data predates the edit. - */ -export function hasLiveOverride(graph: SceneGraph, nodeId: string, field: string): boolean { - const instance = findInstanceAncestor(graph, nodeId) - if (!instance) return false - const key = nodeId === instance.id ? field : `${nodeId}:${field}` - return key in instance.overrides -} diff --git a/packages/fig/src/instance-overrides/symbol/overrides.ts b/packages/fig/src/instance-overrides/symbol/overrides.ts index cb9f6d56a..6226ff4ed 100644 --- a/packages/fig/src/instance-overrides/symbol/overrides.ts +++ b/packages/fig/src/instance-overrides/symbol/overrides.ts @@ -1,4 +1,5 @@ -import { hasLiveOverride } from '../live-overrides' +import { hasInstanceOverride } from '@open-pencil/scene-graph' + import { applyOverridePatch } from '../patches' import { resolveOverrideTarget } from '../resolve' import type { OverrideContext } from '../types' @@ -16,7 +17,9 @@ function dropLiveOverriddenFields( ): void { if (!patch?.props) return const kept = Object.fromEntries( - Object.entries(patch.props).filter(([field]) => !hasLiveOverride(ctx.graph, targetId, field)) + Object.entries(patch.props).filter( + ([field]) => !hasInstanceOverride(ctx.graph, targetId, field) + ) ) patch.props = Object.keys(kept).length > 0 ? (kept as typeof patch.props) : undefined } diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 42aea1d09..162618df8 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -5,8 +5,7 @@ export * from './copy' export { copyInstanceComponentProps, hasInstanceOverride, - INSTANCE_SYNC_FIELDS, - INSTANCE_SYNC_TEXT_PROPS, + INSTANCE_SYNC_PROPS, recordInstanceOverride } from './instances' export * from './snap' diff --git a/packages/scene-graph/src/instances.ts b/packages/scene-graph/src/instances.ts index b50a31386..6aadc86f5 100644 --- a/packages/scene-graph/src/instances.ts +++ b/packages/scene-graph/src/instances.ts @@ -294,7 +294,16 @@ export function findInstanceAncestor(graph: SceneGraph, nodeId: string): SceneNo } /** - * Marks `nodeId`'s `fields` as instance overrides so resync (swapInstanceComponent, + * True when a node field is protected from instance synchronization. + */ +export function hasInstanceOverride(graph: SceneGraph, nodeId: string, field: string): boolean { + const instance = findInstanceAncestor(graph, nodeId) + if (!instance) return false + const key = nodeId === instance.id ? field : `${nodeId}:${field}` + return key in instance.overrides +} + +/* * syncInstances) won't clobber them, and — if `nodeId` sits inside an INSTANCE — * so the .fig exporter knows to write the diff out as a symbol override. A no-op * for fields outside INSTANCE_SYNC_PROPS or nodes with no INSTANCE ancestor. From fcbd938b807bc7511d612395181abc86c1b34f4f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 1 Sep 2026 01:07:19 +0300 Subject: [PATCH 6/9] fix(fig): consolidate instance override propagation --- packages/fig/src/instance-overrides/index.ts | 7 +------ .../engine/figma/api/create/instance-fill-override.test.ts | 4 +++- .../override-resave.test.ts} | 0 .../override-roundtrip.test.ts} | 4 +++- .../swap-roundtrip.test.ts} | 0 5 files changed, 7 insertions(+), 8 deletions(-) rename tests/engine/io/fig/{instance-fill-override-resave.test.ts => instance/override-resave.test.ts} (100%) rename tests/engine/io/fig/{instance-fill-override-roundtrip.test.ts => instance/override-roundtrip.test.ts} (94%) rename tests/engine/io/fig/{instance-swap-roundtrip.test.ts => instance/swap-roundtrip.test.ts} (100%) diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index eb264976b..138fbff3b 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -18,12 +18,7 @@ import { isEqual } from 'es-toolkit/predicate' import { guidToString, resolvedNumericBindingUpdate } from '@open-pencil/fig/node-change' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' -import { hasInstanceOverride } from '@open-pencil/scene-graph' -import { - INSTANCE_SYNC_PROPS, - INSTANCE_SYNC_TEXT_PROPS, - hasInstanceOverride -} from '@open-pencil/scene-graph' +import { INSTANCE_SYNC_FIELDS, hasInstanceOverride } from '@open-pencil/scene-graph' import { copyFills, copyStyleRuns, diff --git a/tests/engine/figma/api/create/instance-fill-override.test.ts b/tests/engine/figma/api/create/instance-fill-override.test.ts index aa24a70f3..05bf7d943 100644 --- a/tests/engine/figma/api/create/instance-fill-override.test.ts +++ b/tests/engine/figma/api/create/instance-fill-override.test.ts @@ -14,7 +14,9 @@ describe('setting fills on an instance descendant', () => { const vectorProxy = api.getNodeById(vector.id) if (!vectorProxy) throw new Error('vector proxy not found') - vectorProxy.fills = [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }] + vectorProxy.fills = [ + { type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true } + ] const raw = api.graph.getNode(instance.id) expect(raw?.overrides[`${vector.id}:fills`]).toBe(true) diff --git a/tests/engine/io/fig/instance-fill-override-resave.test.ts b/tests/engine/io/fig/instance/override-resave.test.ts similarity index 100% rename from tests/engine/io/fig/instance-fill-override-resave.test.ts rename to tests/engine/io/fig/instance/override-resave.test.ts diff --git a/tests/engine/io/fig/instance-fill-override-roundtrip.test.ts b/tests/engine/io/fig/instance/override-roundtrip.test.ts similarity index 94% rename from tests/engine/io/fig/instance-fill-override-roundtrip.test.ts rename to tests/engine/io/fig/instance/override-roundtrip.test.ts index 2b74b175c..e6eb1ee83 100644 --- a/tests/engine/io/fig/instance-fill-override-roundtrip.test.ts +++ b/tests/engine/io/fig/instance/override-roundtrip.test.ts @@ -37,7 +37,9 @@ describe('instance descendant fill override round trip', () => { const reloadedPath = reloadedInstance ? reloaded.getChildren(reloadedInstance.id)[0] : undefined expect(reloadedPath?.fills[0]?.color).toEqual({ r: 0, g: 0, b: 1, a: 1 }) - const reloadedIcon = reloaded.getAllNodes().find((n) => n.type === 'COMPONENT' && n.name === 'Icon') + const reloadedIcon = reloaded + .getAllNodes() + .find((n) => n.type === 'COMPONENT' && n.name === 'Icon') const reloadedIconPath = reloadedIcon ? reloaded.getChildren(reloadedIcon.id)[0] : undefined expect(reloadedIconPath?.fills[0]?.color).toEqual({ r: 0, g: 0, b: 0, a: 1 }) }) diff --git a/tests/engine/io/fig/instance-swap-roundtrip.test.ts b/tests/engine/io/fig/instance/swap-roundtrip.test.ts similarity index 100% rename from tests/engine/io/fig/instance-swap-roundtrip.test.ts rename to tests/engine/io/fig/instance/swap-roundtrip.test.ts From 2e49ebc6d5db05bc89bb7065d73af7b86b4cd1b5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 1 Sep 2026 01:29:03 +0300 Subject: [PATCH 7/9] fix(scene-graph): export instance sync fields --- packages/scene-graph/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 162618df8..92f94077e 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -5,7 +5,9 @@ export * from './copy' export { copyInstanceComponentProps, hasInstanceOverride, + INSTANCE_SYNC_FIELDS, INSTANCE_SYNC_PROPS, + INSTANCE_SYNC_TEXT_PROPS, recordInstanceOverride } from './instances' export * from './snap' From 4eb9694d581c9f5eaa0767339e6a9031610e867e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 1 Sep 2026 02:01:05 +0300 Subject: [PATCH 8/9] fix(fig): clean up consolidated instance override fix --- packages/fig/src/instance-overrides/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index 138fbff3b..1ec27da79 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -18,7 +18,7 @@ import { isEqual } from 'es-toolkit/predicate' import { guidToString, resolvedNumericBindingUpdate } from '@open-pencil/fig/node-change' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' -import { INSTANCE_SYNC_FIELDS, hasInstanceOverride } from '@open-pencil/scene-graph' +import { hasInstanceOverride } from '@open-pencil/scene-graph' import { copyFills, copyStyleRuns, From 3e47b14e5900f4988abc6037d62b103d5b0e73e1 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 1 Sep 2026 04:57:43 +0300 Subject: [PATCH 9/9] fix(fig): preserve text and stroke overrides --- packages/core/src/figma-api/strokes.ts | 4 ++++ packages/core/src/figma-api/text.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/core/src/figma-api/strokes.ts b/packages/core/src/figma-api/strokes.ts index 3287aa226..28b912505 100644 --- a/packages/core/src/figma-api/strokes.ts +++ b/packages/core/src/figma-api/strokes.ts @@ -1,3 +1,4 @@ +import { recordInstanceOverride } from '@open-pencil/scene-graph' import type { SceneGraph, SceneNode, Stroke } from '@open-pencil/scene-graph' import { copyStrokes } from '@open-pencil/scene-graph/copy' @@ -6,6 +7,7 @@ export function setFirstStrokeWeight(graph: SceneGraph, node: SceneNode, weight: const strokes = copyStrokes(node.strokes) strokes[0].weight = weight graph.updateNode(node.id, { strokes }) + recordInstanceOverride(graph, node.id, ['strokes']) } export function setFirstStrokeAlign(graph: SceneGraph, node: SceneNode, align: string): void { @@ -13,6 +15,7 @@ export function setFirstStrokeAlign(graph: SceneGraph, node: SceneNode, align: s const strokes = copyStrokes(node.strokes) strokes[0].align = align as Stroke['align'] graph.updateNode(node.id, { strokes }) + recordInstanceOverride(graph, node.id, ['strokes']) } export function setIndependentStrokeWeight( @@ -25,4 +28,5 @@ export function setIndependentStrokeWeight( [field]: value, independentStrokeWeights: true }) + recordInstanceOverride(graph, nodeId, [field, 'independentStrokeWeights']) } diff --git a/packages/core/src/figma-api/text.ts b/packages/core/src/figma-api/text.ts index ed66a42f1..fc6ca988e 100644 --- a/packages/core/src/figma-api/text.ts +++ b/packages/core/src/figma-api/text.ts @@ -1,3 +1,4 @@ +import { recordInstanceOverride } from '@open-pencil/scene-graph' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import { styleNameToWeight, weightToStyleName, type FigmaFontName } from './fonts' @@ -13,6 +14,7 @@ export function setFontName(graph: SceneGraph, nodeId: string, fontName: FigmaFo fontWeight: weight, italic }) + recordInstanceOverride(graph, nodeId, ['fontFamily', 'fontWeight']) } export function insertCharacters( @@ -23,6 +25,7 @@ export function insertCharacters( ): void { const text = node.text.slice(0, start) + characters + node.text.slice(start) graph.updateNode(node.id, { text }) + recordInstanceOverride(graph, node.id, ['text']) } export function deleteCharacters( @@ -33,4 +36,5 @@ export function deleteCharacters( ): void { const text = node.text.slice(0, start) + node.text.slice(end) graph.updateNode(node.id, { text }) + recordInstanceOverride(graph, node.id, ['text']) }