Merge pull request #504 from open-pencil/fig-export-memory

perf(fig): reduce export graph cloning
This commit is contained in:
Danila Poyarkov 2026-08-13 20:32:21 +03:00 committed by GitHub
commit 9676bfe31b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 66 additions and 3 deletions

View file

@ -8,6 +8,10 @@
- Allow supported AI model profiles to set a provider-specific reasoning effort. (#454)
- Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API and MCP tooling. (#503)
### Performance
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
### Fixed
- Report exhausted provider credit, request failures, and output-token limits through localized chat toasts and copied diagnostics. (#451, #454)

View file

@ -23,7 +23,7 @@ import {
makeDocumentNodeChange,
makeCanvasNodeChange
} from '#core/kiwi/fig/node-change/serialize'
import { deserializeSceneGraph, serializeSceneGraph } from '#core/kiwi/fig/parse/transfer'
import { cloneSceneGraphForFigExport } from '#core/kiwi/fig/parse/transfer'
const THUMBNAIL_1X1 = decodeBase64(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
@ -388,7 +388,7 @@ export async function exportFigFile(
): Promise<Uint8Array> {
// Lazy population synchronizes component trees and therefore mutates its graph. Saving must not
// rewrite the live editor document or restore component values over edits made by the user.
const graph = deserializeSceneGraph(structuredClone(serializeSceneGraph(sourceGraph)))
const graph = cloneSceneGraphForFigExport(sourceGraph)
populateAllLazyFigImportRoots(graph)
await initCodec()

View file

@ -86,6 +86,40 @@ export function serializedSceneGraphTransferList(data: SerializedSceneGraph): Tr
return [...buffers]
}
/**
* Clone the graph state that lazy FIG population may mutate while retaining immutable imported
* resources by reference. Population replaces node fields and mutates child ID arrays, but only
* reads image bytes, variables, source changes, GUID mappings, blobs, and schema bytes.
*/
export function cloneSceneGraphForFigExport(graph: SceneGraph): SceneGraph {
const cloned = new SceneGraph()
cloned.rootId = graph.rootId
cloned.nodes = new Map(
[...graph.nodes].map(([id, node]) => [id, { ...node, childIds: [...node.childIds] }])
)
cloned.images = new Map(graph.images)
cloned.variables = new Map(graph.variables)
cloned.variableCollections = new Map(graph.variableCollections)
cloned.activeMode = new Map(graph.activeMode)
cloned.instanceIndex = new Map(
[...graph.instanceIndex].map(([id, nodeIds]) => [id, new Set(nodeIds)])
)
cloned.figKiwiVersion = graph.figKiwiVersion
cloned.figSchemaDeflated = graph.figSchemaDeflated
cloned.documentColorSpace = graph.documentColorSpace
const lazyFigImport = getLazyFigImportContext(graph)
if (lazyFigImport) {
setLazyFigImportContext(cloned, {
changeMap: lazyFigImport.changeMap,
guidToNodeId: lazyFigImport.guidToNodeId,
blobs: lazyFigImport.blobs,
populatedRootIds: new Set(lazyFigImport.populatedRootIds)
})
}
return cloned
}
export function deserializeSceneGraph(data: SerializedSceneGraph): SceneGraph {
const graph = new SceneGraph()
graph.rootId = data.rootId

View file

@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec, parseFigFile } from '@open-pencil/core'
import { setLazyFigImportContext } from '@open-pencil/core/kiwi/fig/lazy-import'
import {
getLazyFigImportContext,
setLazyFigImportContext
} from '@open-pencil/core/kiwi/fig/lazy-import'
import { cloneSceneGraphForFigExport } from '@open-pencil/core/kiwi/fig/parse/transfer'
import { SceneGraph } from '@open-pencil/scene-graph'
function lazyExportGraph() {
@ -39,6 +43,27 @@ function createEditedInstance(
}
describe('FIG population export lifecycle', () => {
test('isolates mutable graph state while sharing immutable binary resources', () => {
const { graph } = lazyExportGraph()
const image = new Uint8Array([1, 2, 3])
graph.images.set('image', image)
const context = getLazyFigImportContext(graph)
expect(context).toBeDefined()
const clone = cloneSceneGraphForFigExport(graph)
const cloneContext = getLazyFigImportContext(clone)
const firstPage = graph.getPages()[0]
clone.getNode(firstPage.id)?.childIds.push('export-only')
cloneContext?.populatedRootIds.add('export-only')
expect(graph.getNode(firstPage.id)?.childIds).not.toContain('export-only')
expect(context?.populatedRootIds).not.toContain('export-only')
expect(clone.images.get('image')).toBe(image)
expect(cloneContext?.changeMap).toBe(context?.changeMap)
expect(cloneContext?.guidToNodeId).toBe(context?.guidToNodeId)
expect(cloneContext?.blobs).toBe(context?.blobs)
})
test('exports all remaining lazy pages after a partial visit', async () => {
await initCodec()
const { graph, instance } = lazyExportGraph()