From 7b011bceafb840f4352f60cf39cbbe3ea8daafd7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 7 Oct 2026 21:33:09 +0000 Subject: [PATCH] fix(fig): treat the unset override key as no key (#967) Figma saves layers without an override key with Kiwi's all-ones GUID, so many siblings share it. The importer stored it as a real key, and export addressed each instance layer by that key, so two such siblings got the same derived geometry address and export threw "Ambiguous instance geometry address". Instance linkage and override path matching also treated the shared value as an identity. The importer now reads the unset GUID as no key, through an isUnsetGuid helper that the cleared-style and default-slot checks share. The Preline UI kit has 1,165 such text and rounded rectangle layers; after an edit it now exports and reopens with all 104 pages. --- .../src/instance-overrides/source-index.ts | 6 +- packages/fig/src/instance-overrides/types.ts | 8 +-- packages/fig/src/node-change/convert.ts | 16 ++--- .../node-change/instance-geometry.test.ts | 68 ++++++++++++++++++- packages/kiwi/src/fig/guid.ts | 10 +++ 5 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/fig/src/instance-overrides/source-index.ts b/packages/fig/src/instance-overrides/source-index.ts index 70cc6fec6..c7b74ae67 100644 --- a/packages/fig/src/instance-overrides/source-index.ts +++ b/packages/fig/src/instance-overrides/source-index.ts @@ -1,5 +1,5 @@ import type { GUID, NodeChange } from '@open-pencil/kiwi/fig/codec' -import { guidToString } from '@open-pencil/kiwi/fig/guid' +import { guidToString, isUnsetGuid } from '@open-pencil/kiwi/fig/guid' import { findWithinBoundary, type TreeShape } from './tree' @@ -62,7 +62,9 @@ export function readOverrideKey(value: unknown): GUID | undefined { if (!value || typeof value !== 'object' || !('sessionID' in value) || !('localID' in value)) return undefined if (typeof value.sessionID !== 'number' || typeof value.localID !== 'number') return undefined - return { sessionID: value.sessionID, localID: value.localID } + const key = { sessionID: value.sessionID, localID: value.localID } + // Layers without an override key share the unset GUID, so it identifies none of them. + return isUnsetGuid(key) ? undefined : key } /** A path segment addresses a record by GUID or by its stable override key. */ diff --git a/packages/fig/src/instance-overrides/types.ts b/packages/fig/src/instance-overrides/types.ts index 6e6b3e146..620bc6e41 100644 --- a/packages/fig/src/instance-overrides/types.ts +++ b/packages/fig/src/instance-overrides/types.ts @@ -1,4 +1,5 @@ import type { GUID, NodeChange, VariableConsumptionEntry } from '@open-pencil/kiwi/fig/codec' +import { isUnsetGuid, UNSET_GUID } from '@open-pencil/kiwi/fig/guid' import type { Matrix, Vector } from '@open-pencil/scene-graph/primitives' export interface VariableConsumptionMapFields { @@ -59,16 +60,13 @@ export type ComponentPropValue = { } /** Figma's slot value for "the component's own content", the default of every slot property. */ -export const DEFAULT_SLOT_CONTENT: Readonly = { sessionID: 0xffffffff, localID: 0xffffffff } +export const DEFAULT_SLOT_CONTENT: Readonly = UNSET_GUID /** The content frame a slot value names, unless it names the component's own content. */ export function assignedSlotContent(value: ComponentPropValue | undefined): GUID | undefined { const guid = value?.slotContentIdValue?.guid if (!guid) return undefined - return guid.sessionID === DEFAULT_SLOT_CONTENT.sessionID && - guid.localID === DEFAULT_SLOT_CONTENT.localID - ? undefined - : guid + return isUnsetGuid(guid) ? undefined : guid } export interface ComponentPropAssignment { diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index ec98114f5..e36d5cf98 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -1,4 +1,4 @@ -import { guidToString } from '@open-pencil/kiwi/fig/guid' +import { guidToString, isUnsetGuid } from '@open-pencil/kiwi/fig/guid' import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT, @@ -510,14 +510,8 @@ function styleRefId(value: unknown): string | null { if (!value || typeof value !== 'object' || !('guid' in value)) return null const guid = value.guid if (!guid || typeof guid !== 'object') return null - // Kiwi's all-ones GUID denotes an explicitly cleared style reference. - if ( - 'sessionID' in guid && - 'localID' in guid && - guid.sessionID === 0xffffffff && - guid.localID === 0xffffffff - ) - return null + // The unset GUID denotes an explicitly cleared style reference. + if (isUnsetGuid(guid as GUID)) return null return guidToString(guid as GUID) } @@ -902,11 +896,13 @@ type ComponentMetadataProps = Pick< | 'variantPropSpecs' > +/** Null for a missing GUID and for the unset GUID, which many layers share and names none. */ function guidToStringOrNull(value: unknown): string | null { if (!value || typeof value !== 'object') return null const guid = value as Partial if (typeof guid.sessionID !== 'number' || typeof guid.localID !== 'number') return null - return guidToString({ sessionID: guid.sessionID, localID: guid.localID }) + const parsed = { sessionID: guid.sessionID, localID: guid.localID } + return isUnsetGuid(parsed) ? null : guidToString(parsed) } function stringOrNull(value: unknown): string | null { diff --git a/packages/fig/tests/node-change/instance-geometry.test.ts b/packages/fig/tests/node-change/instance-geometry.test.ts index add5f9fd8..d089e88e4 100644 --- a/packages/fig/tests/node-change/instance-geometry.test.ts +++ b/packages/fig/tests/node-change/instance-geometry.test.ts @@ -1,9 +1,12 @@ import { expect, test } from 'bun:test' import type { DerivedSymbolOverride } from '#fig/instance-overrides/types' +import { sceneNodeToKiwi } from '#fig/node-change/index' import { snapshotInstanceGeometry } from '#fig/node-change/instance/geometry' -import { stringToGuid } from '@open-pencil/kiwi/fig/guid' +import { materializeDocument } from '@open-pencil/fig' +import type { NodeChange } from '@open-pencil/kiwi/fig/codec' +import { stringToGuid, UNSET_GUID } from '@open-pencil/kiwi/fig/guid' import { SceneGraph } from '@open-pencil/scene-graph' import { expectDefined } from '../helpers/assert' @@ -42,3 +45,66 @@ test('geometry snapshots reject ambiguous occurrence addresses', () => { })) ).toThrow('Ambiguous instance geometry address') }) + +// Figma saves layers that have no override key with the unset GUID, so many siblings share it. +test('layers saved with the unset override key keep distinct geometry addresses', () => { + const at = (localID: number) => ({ sessionID: 1, localID }) + const transform = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } + const { graph } = materializeDocument([ + { guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document', phase: 'CREATED' }, + { + guid: { sessionID: 0, localID: 1 }, + parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, + type: 'CANVAS', + name: 'Page', + phase: 'CREATED' + }, + { + guid: at(1), + parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, + type: 'SYMBOL', + name: 'Card', + phase: 'CREATED', + size: { x: 100, y: 40 }, + transform + }, + ...['Title', 'Body'].map((name, index) => ({ + guid: at(2 + index), + overrideKey: { ...UNSET_GUID }, + parentIndex: { guid: at(1), position: index === 0 ? '!' : '"' }, + type: 'ROUNDED_RECTANGLE', + name, + phase: 'CREATED', + size: { x: 100, y: 20 }, + transform: { ...transform, m12: index * 20 } + })), + { + guid: at(4), + parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, + type: 'INSTANCE', + name: 'Card use', + phase: 'CREATED', + size: { x: 100, y: 40 }, + transform, + symbolData: { symbolID: at(1) } + } + ] as NodeChange[]) + const page = graph.getPages()[0] + const instance = expectDefined( + graph.getChildren(page.id).find((node) => node.type === 'INSTANCE') + ) + expect(graph.getChildren(instance.id).map((node) => node.overrideKey)).toEqual([null, null]) + + const [change] = sceneNodeToKiwi( + instance, + { sessionID: 0, localID: 1 }, + 1, + { value: 10 }, + graph, + [] + ) + const derived = (change.derivedSymbolData ?? []) as DerivedSymbolOverride[] + const paths = derived.map((entry) => JSON.stringify(entry.guidPath?.guids)) + expect(paths).toHaveLength(2) + expect(new Set(paths).size).toBe(2) +}) diff --git a/packages/kiwi/src/fig/guid.ts b/packages/kiwi/src/fig/guid.ts index 73aa97342..f86d175e4 100644 --- a/packages/kiwi/src/fig/guid.ts +++ b/packages/kiwi/src/fig/guid.ts @@ -1,5 +1,15 @@ import type { GUID } from './types' +/** + * Kiwi's all-ones GUID names no node: a cleared style reference, a slot's own content, or a + * layer without an override key. + */ +export const UNSET_GUID: Readonly = { sessionID: 0xffffffff, localID: 0xffffffff } + +export function isUnsetGuid(guid: GUID): boolean { + return guid.sessionID === UNSET_GUID.sessionID && guid.localID === UNSET_GUID.localID +} + export function guidToString(guid: GUID): string { return `${guid.sessionID}:${guid.localID}` }