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.
This commit is contained in:
Danila Poyarkov 2026-10-07 21:33:09 +00:00 committed by GitHub
parent 6e68366bc6
commit 7b011bceaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 90 additions and 18 deletions

View file

@ -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. */

View file

@ -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<GUID> = { sessionID: 0xffffffff, localID: 0xffffffff }
export const DEFAULT_SLOT_CONTENT: Readonly<GUID> = 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 {

View file

@ -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<GUID>
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 {

View file

@ -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)
})

View file

@ -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<GUID> = { 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}`
}