From 6bd319881e862411f3872e24d290245bac71b6d5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 13 Aug 2026 17:56:37 +0300 Subject: [PATCH] perf(fig): reduce export graph cloning - Clone mutable node shells without duplicating imported binary resources - Preserve live graph isolation during lazy population --- CHANGELOG.md | 4 +++ packages/core/src/io/formats/fig/export.ts | 4 +-- packages/core/src/kiwi/fig/parse/transfer.ts | 34 +++++++++++++++++++ .../io/fig/export/lazy-population.test.ts | 27 ++++++++++++++- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae43a8c5..f838d9da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Allow supported AI model profiles to set a provider-specific reasoning effort. (#454) +### 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) diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index 62cf76472..d3f0d3d44 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -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 { // 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() diff --git a/packages/core/src/kiwi/fig/parse/transfer.ts b/packages/core/src/kiwi/fig/parse/transfer.ts index b8bed5bfb..4fe0154b8 100644 --- a/packages/core/src/kiwi/fig/parse/transfer.ts +++ b/packages/core/src/kiwi/fig/parse/transfer.ts @@ -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 diff --git a/tests/engine/io/fig/export/lazy-population.test.ts b/tests/engine/io/fig/export/lazy-population.test.ts index 035293b32..ceba69948 100644 --- a/tests/engine/io/fig/export/lazy-population.test.ts +++ b/tests/engine/io/fig/export/lazy-population.test.ts @@ -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()