fix(fig): keep Figma's order keys on instances and untouched siblings (#819)

* fix(fig): keep Figma's order keys on instances and untouched siblings

Materialized instance occurrences were built without the record's
parentIndex position, so every imported instance lost its key and was
renumbered on save. They now carry it like other imported nodes.

Sibling keys anchored greedily on the first imported key, so a layer moved
up past its siblings kept its key and pushed every sibling after it onto a
new one. Keep the longest increasing run of imported keys instead, so only
the moved and inserted layers are re-keyed.

* fix(fig): don't anchor sibling keys on one nothing can precede

A moved layer in front of the lowest key ('!') left no room below it, so
the anchor was dropped and every sibling up to the next anchor re-keyed.
Such a key no longer starts the kept run when a sibling precedes it.
This commit is contained in:
Danila Poyarkov 2026-10-04 10:16:01 +00:00 committed by GitHub
parent 6a3960a5d4
commit 12b6662ba3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 57 additions and 7 deletions

View file

@ -19,6 +19,8 @@ function occurrenceMetadata(
converted: ReturnType<typeof nodeChangeToProps>
) {
const metadata = createDefaultSourceMetadata()
// Figma's own sibling key, so re-export keeps the order Figma saved.
metadata.orderKey = current.properties.parentIndex?.position ?? null
metadata.fig.layout = converted.source?.fig.layout ?? null
metadata.fig.uniformScaleFactor = symbolDataOf(current.properties)?.uniformScaleFactor ?? null
return metadata

View file

@ -81,10 +81,34 @@ export function orderKeyBetween(lo: string | null, hi: string | null): string |
return hi.slice(0, i + 1) + (orderKeyBetween(null, rest) ?? '')
}
/** Indices of the longest strictly increasing run of keys, so a moved layer re-keys alone. */
function increasingKeyIndices(sourceKeys: ReadonlyArray<string | null | undefined>): Set<number> {
const tails: number[] = []
const previous = new Map<number, number>()
for (let index = 0; index < sourceKeys.length; index++) {
const key = sourceKeys[index]
if (!key) continue
let low = 0
let high = tails.length
while (low < high) {
const mid = (low + high) >> 1
if ((sourceKeys[tails[mid]] ?? '') < key) low = mid + 1
else high = mid
}
// A key with nothing below it cannot start the run once a sibling has to precede it.
if (low === 0 && index > 0 && orderKeyBetween(null, key) === null) continue
if (low > 0) previous.set(index, tails[low - 1])
tails[low] = index
}
const kept = new Set<number>()
for (let index = tails.at(-1); index !== undefined; index = previous.get(index)) kept.add(index)
return kept
}
/**
* Order keys for siblings in their current order. Imported keys are kept while they still
* increase; siblings without a usable key get the index key when it fits between its
* neighbours, and otherwise a key between them, so no two siblings share a key.
* Order keys for siblings in their current order. The longest increasing run of imported keys
* is kept; other siblings get the index key when it fits between their neighbours, and
* otherwise a key between them, so no two siblings share a key.
*/
export function siblingOrderKeys(sourceKeys: ReadonlyArray<string | null | undefined>): string[] {
const keys: string[] = []
@ -107,9 +131,10 @@ export function siblingOrderKeys(sourceKeys: ReadonlyArray<string | null | undef
return filled
}
const kept = increasingKeyIndices(sourceKeys)
for (let index = 0; index < sourceKeys.length; index++) {
const key = sourceKeys[index]
const filled = key && (prev === null || key > prev) ? fill(key) : null
const filled = key && kept.has(index) ? fill(key) : null
if (key && filled) {
pending.forEach((pendingIndex, i) => (keys[pendingIndex] = filled[i]))
keys[index] = key

View file

@ -46,6 +46,15 @@ test('materializes separate occurrence nodes with direct component identities an
for (const node of result.nodes.values()) expect(node.source.id).toBeNull()
})
test('materialized occurrences keep the order keys Figma saved', () => {
const { occurrence, graph, page, components } = setup()
const result = materializeInstance(graph, page.id, occurrence, components)
for (const [source, node] of result.nodes) {
expect(node.source.orderKey).toBe(source.properties.parentIndex?.position ?? null)
}
expect(result.root.source.orderKey).not.toBeNull()
})
test('materialization does not share editable paint payloads with interpreted occurrences', () => {
const { occurrence, graph, page, components } = setup()
occurrence.properties.fillPaints = [

View file

@ -81,9 +81,23 @@ describe('siblingOrderKeys', () => {
expectStrictlyIncreasing(keys)
})
test('re-keys imported siblings that were moved out of order', () => {
const keys = siblingOrderKeys(['#', '!', '"'])
expect(keys[0]).toBe('#')
test('re-keys only the sibling that was moved out of order', () => {
const keys = siblingOrderKeys(['$', '!O', '"', '#'])
expect(keys.slice(1)).toEqual(['!O', '"', '#'])
expectStrictlyIncreasing(keys)
})
test('does not anchor on the lowest key when a sibling has to precede it', () => {
const keys = siblingOrderKeys(['"', '!', '#'])
expect(keys[0]).toBe('"')
expect(keys[2]).toBe('#')
expectStrictlyIncreasing(keys)
})
test('keeps the siblings a moved layer jumped over', () => {
const keys = siblingOrderKeys([null, '~', '!O', '"', '#'])
expect(keys.slice(2)).toEqual(['!O', '"', '#'])
expect(new Set(keys).size).toBe(keys.length)
expectStrictlyIncreasing(keys)
})
})