From 12b6662ba3690ca776e021d8a3c3135f4dfb99fa Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 4 Oct 2026 10:16:01 +0000 Subject: [PATCH] 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. --- .../materialize-instance.ts | 2 ++ packages/fig/src/node-change/basics.ts | 33 ++++++++++++++++--- .../instance-overrides/materialize.test.ts | 9 +++++ .../fig/tests/node-change/order-keys.test.ts | 20 +++++++++-- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/fig/src/instance-overrides/materialize-instance.ts b/packages/fig/src/instance-overrides/materialize-instance.ts index e64453fd6..09449af51 100644 --- a/packages/fig/src/instance-overrides/materialize-instance.ts +++ b/packages/fig/src/instance-overrides/materialize-instance.ts @@ -19,6 +19,8 @@ function occurrenceMetadata( converted: ReturnType ) { 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 diff --git a/packages/fig/src/node-change/basics.ts b/packages/fig/src/node-change/basics.ts index 0d23537b1..dd12c961d 100644 --- a/packages/fig/src/node-change/basics.ts +++ b/packages/fig/src/node-change/basics.ts @@ -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): Set { + const tails: number[] = [] + const previous = new Map() + 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() + 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[] { const keys: string[] = [] @@ -107,9 +131,10 @@ export function siblingOrderKeys(sourceKeys: ReadonlyArray 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 diff --git a/packages/fig/tests/instance-overrides/materialize.test.ts b/packages/fig/tests/instance-overrides/materialize.test.ts index 615d1cc66..eaf70b90a 100644 --- a/packages/fig/tests/instance-overrides/materialize.test.ts +++ b/packages/fig/tests/instance-overrides/materialize.test.ts @@ -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 = [ diff --git a/packages/fig/tests/node-change/order-keys.test.ts b/packages/fig/tests/node-change/order-keys.test.ts index fbfe56c08..e37f95f35 100644 --- a/packages/fig/tests/node-change/order-keys.test.ts +++ b/packages/fig/tests/node-change/order-keys.test.ts @@ -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) }) })