diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index 842ef0d76..2d4292d00 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -8,6 +8,9 @@ import { UndoManager } from '@open-pencil/scene-graph/undo' import type { SkiaRenderer } from '#core/canvas/renderer' import { prefetchFigmaSchema } from '#core/clipboard' import { IS_BROWSER } from '#core/constants' +import { clearLazyFigImportContext } from '#core/kiwi/fig/lazy-import' +import { releaseFigPopulationWorker } from '#core/kiwi/fig/population/client' +import { releaseOriginalFigArchive } from '#core/kiwi/fig/session/original-archive' import { setTextMeasurer } from '#core/layout' import { TextEditor } from '#core/text/editor' import { fontManager } from '#core/text/fonts' @@ -123,7 +126,7 @@ export function createEditor(options?: EditorOptions) { const { runLayoutForNode } = createLayoutRunner(() => _graph) const { scheduleComponentSync } = createComponentSyncScheduler(() => _graph, requestRender) - const { subscribeToGraph } = createGraphEventSubscription({ + const { subscribeToGraph, unsubscribeFromGraph } = createGraphEventSubscription({ getGraph: () => _graph, getRenderers: () => _renderers, scheduleComponentSync, @@ -220,6 +223,17 @@ export function createEditor(options?: EditorOptions) { requestRender() } + function dispose() { + stopFontResolutionEvents() + unsubscribeFromGraph() + } + + function releaseGraphResources() { + releaseFigPopulationWorker(_graph) + releaseOriginalFigArchive(_graph) + clearLazyFigImportContext(_graph) + } + return { get graph() { return _graph @@ -247,7 +261,8 @@ export function createEditor(options?: EditorOptions) { removeCanvasRenderer, replaceGraph, subscribeToGraph, - dispose: stopFontResolutionEvents, + dispose, + releaseGraphResources, // Selection ...selection, diff --git a/packages/core/src/editor/graph-events.ts b/packages/core/src/editor/graph-events.ts index 86c40c6df..688d41c3f 100644 --- a/packages/core/src/editor/graph-events.ts +++ b/packages/core/src/editor/graph-events.ts @@ -104,5 +104,10 @@ export function createGraphEventSubscription(options: GraphEventOptions) { }) } - return { subscribeToGraph } + function unsubscribeFromGraph() { + unbindGraphEvents?.() + unbindGraphEvents = null + } + + return { subscribeToGraph, unsubscribeFromGraph } } diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index a3bf788b6..94ddc8d75 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -31,6 +31,7 @@ import { makeCanvasNodeChange } from '#core/kiwi/fig/node-change/serialize' import { cloneSceneGraphForFigExport } from '#core/kiwi/fig/parse/transfer' +import { originalFigArchive } from '#core/kiwi/fig/session/original-archive' const THUMBNAIL_1X1 = decodeBase64( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' @@ -445,8 +446,8 @@ export async function exportFigFile( pageId?: string, renderHeadlessThumbnail = false ): 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 originalArchive = await originalFigArchive(sourceGraph) + if (originalArchive) return originalArchive.slice() const graph = cloneSceneGraphForFigExport(sourceGraph) populateAllLazyFigImportRoots(graph) await initCodec() diff --git a/packages/core/src/io/formats/fig/read.ts b/packages/core/src/io/formats/fig/read.ts index a58079872..3c7cde067 100644 --- a/packages/core/src/io/formats/fig/read.ts +++ b/packages/core/src/io/formats/fig/read.ts @@ -4,10 +4,14 @@ import type { SceneGraph } from '@open-pencil/scene-graph' import { IS_BROWSER } from '#core/constants' import { importNodeChanges } from '#core/kiwi/fig/import' -import { createFigParseWorker } from '#core/kiwi/fig/parse/client' import { deserializeSceneGraph } from '#core/kiwi/fig/parse/transfer' -import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer' -import { registerFigPopulationWorker } from '#core/kiwi/fig/population/client' +import { + registerFigPopulationWorker, + registerOriginalArchiveRequest +} from '#core/kiwi/fig/population/client' +import { createFigSessionWorker } from '#core/kiwi/fig/session/client' +import type { FigSessionOpenRequest, FigSessionResponse } from '#core/kiwi/fig/session/protocol' +import { randomHex } from '#core/random' export interface ParseFigFileOptions { populate?: 'all' | 'first-page' | 'none' @@ -29,37 +33,37 @@ function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {} return graph } -interface WorkerGraphResult { - type: 'graph' - graph?: SerializedSceneGraph - error?: string -} - -interface WorkerPageManifestResult { - type: 'page-manifest' - pages: FigPageManifestEntry[] -} - -type WorkerParseResult = WorkerGraphResult | WorkerPageManifestResult - function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Promise { return new Promise((resolve, reject) => { options.signal?.throwIfAborted() - const worker = createFigParseWorker() + const worker = createFigSessionWorker() + const channel = new MessageChannel() + const pendingArchives = new Map void>() const abort = () => { + channel.port1.postMessage({ type: 'dispose' }) + channel.port1.close() worker.terminate() reject(new DOMException('Aborted', 'AbortError')) } options.signal?.addEventListener('abort', abort, { once: true }) const cleanupAbort = () => options.signal?.removeEventListener('abort', abort) - worker.onmessage = (e: MessageEvent) => { + channel.port1.onmessage = (e: MessageEvent) => { + if (e.data.type === 'original-archive-result') { + const resolveArchive = pendingArchives.get(e.data.requestId) + if (!resolveArchive) return + pendingArchives.delete(e.data.requestId) + resolveArchive(e.data.bytes) + return + } if (e.data.type === 'page-manifest') { options.onPages?.(e.data.pages) return } + if (e.data.type !== 'graph') return if (e.data.error || !e.data.graph) { cleanupAbort() + channel.port1.close() worker.terminate() reject(new Error(e.data.error ?? 'Worker failed to parse .fig file')) return @@ -68,28 +72,46 @@ function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Prom const graph = deserializeSceneGraph(e.data.graph) if (options.populate === 'first-page') { cleanupAbort() - worker.onmessage = null - worker.onerror = null - registerFigPopulationWorker(graph, worker) + registerFigPopulationWorker(graph, worker, channel.port1) + registerOriginalArchiveRequest( + graph, + () => + new Promise((resolveArchive) => { + const requestId = randomHex() + pendingArchives.set(requestId, resolveArchive) + channel.port1.postMessage({ type: 'original-archive', requestId }) + }) + ) } else { cleanupAbort() + channel.port1.close() worker.terminate() } resolve(graph) } catch (error) { cleanupAbort() + channel.port1.close() worker.terminate() reject(error instanceof Error ? error : new Error(String(error))) } } - + channel.port1.start() worker.onerror = (err) => { cleanupAbort() + channel.port1.close() worker.terminate() reject(new Error(err.message || 'Worker failed to parse .fig file')) } - - worker.postMessage({ buffer, options: { populate: options.populate } }, [buffer]) + const workerBuffer = buffer.slice(0) + const archiveBuffer = buffer.slice(0) + const request: FigSessionOpenRequest = { + type: 'open', + originalBuffer: workerBuffer, + archiveBuffer, + options: { populate: options.populate }, + port: channel.port2 + } + worker.postMessage(request, [workerBuffer, archiveBuffer, channel.port2]) }) } @@ -105,7 +127,9 @@ export async function parseFigFile( } catch (error) { if (options.signal?.aborted) throw error console.warn('Worker parsing failed, falling back to main thread:', error) - return parseFigFileSync(copy, options) + const graph = parseFigFileSync(copy, options) + registerOriginalArchiveRequest(graph, async () => new Uint8Array(copy.slice(0))) + return graph } } options.signal?.throwIfAborted() diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index 8ba2a9ba2..ae0cfcd6e 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -566,7 +566,7 @@ export function importNodeChanges( remapInstanceSwapPropertyValues(graph, guidToNodeId) applyVariantPropSpecs(graph) - const firstPageId = graph.getPages()[0]?.id + const firstPageId = graph.getPages().find((page) => !page.internalOnly)?.id const componentPageIds = options.populate === 'first-page' ? componentPageIdsForLazyPopulation(graph) : new Set() const activeRootIds = diff --git a/packages/core/src/kiwi/fig/lazy-import.ts b/packages/core/src/kiwi/fig/lazy-import.ts index e7013aa73..c4cdc6a79 100644 --- a/packages/core/src/kiwi/fig/lazy-import.ts +++ b/packages/core/src/kiwi/fig/lazy-import.ts @@ -19,6 +19,10 @@ export function getLazyFigImportContext(graph: SceneGraph): LazyFigImportContext return lazyFigImportContexts.get(graph) } +export function clearLazyFigImportContext(graph: SceneGraph): void { + lazyFigImportContexts.delete(graph) +} + function applyPopulation( graph: SceneGraph, context: LazyFigImportContext, diff --git a/packages/core/src/kiwi/fig/parse/worker.ts b/packages/core/src/kiwi/fig/parse/worker.ts index 580279b68..6e6593700 100644 --- a/packages/core/src/kiwi/fig/parse/worker.ts +++ b/packages/core/src/kiwi/fig/parse/worker.ts @@ -8,17 +8,13 @@ import { serializedSceneGraphTransferList } from '#core/kiwi/fig/parse/transfer' import { buildFigPopulationDelta, installFigMutationJournal } from '#core/kiwi/fig/population/delta' +import type { FigSessionPopulateRequest } from '#core/kiwi/fig/session/protocol' interface WorkerParseRequest { buffer: ArrayBuffer options?: { populate?: 'all' | 'first-page' } } -interface PopulateRequest { - type: 'populate' - requestId: string - baseRevision: number - pageId: string -} +type PopulateRequest = FigSessionPopulateRequest type WorkerRequest = ArrayBuffer | WorkerParseRequest | PopulateRequest type WorkerPostMessage = (message: unknown, transfer: Transferable[]) => void const postWorkerMessage: WorkerPostMessage = (message, transfer) => { diff --git a/packages/core/src/kiwi/fig/population/client.ts b/packages/core/src/kiwi/fig/population/client.ts index e62afa330..72027e7fe 100644 --- a/packages/core/src/kiwi/fig/population/client.ts +++ b/packages/core/src/kiwi/fig/population/client.ts @@ -1,6 +1,7 @@ import type { SceneGraph } from '@open-pencil/scene-graph' import { getLazyFigImportContext } from '#core/kiwi/fig/lazy-import' +import type { FigSessionResponse } from '#core/kiwi/fig/session/protocol' import { randomHex } from '#core/random' import { applyFigPopulationDelta, type FigPopulationDelta } from './delta' @@ -17,6 +18,12 @@ type WorkerResult = PopulationResult | { type: 'population-error'; error: string const MAX_FIG_POPULATION_WORKER_NODES = 200_000 const FIG_POPULATION_WORKER_TIMEOUT_MS = 30_000 const populationWorkers = new WeakMap() +interface OriginalArchiveRequest { + request: () => Promise + valid: boolean + unbind: () => void +} +const originalArchiveRequests = new WeakMap() export interface FigPopulationWorkerTelemetry { event: 'registered' | 'populate' | 'fallback' | 'stale' | 'terminated' @@ -33,13 +40,21 @@ function emitTelemetry(detail: FigPopulationWorkerTelemetry): void { globalThis.dispatchEvent(new CustomEvent('openpencil:fig-population-worker', { detail })) } -export function registerFigPopulationWorker(graph: SceneGraph, worker: Worker): void { +export function registerFigPopulationWorker( + graph: SceneGraph, + worker: Worker, + port?: MessagePort +): void { if (graph.nodes.size > MAX_FIG_POPULATION_WORKER_NODES) { emitTelemetry({ event: 'fallback', reason: 'oversized' }) - worker.terminate() + if (!port) { + worker.terminate() + return + } + populationWorkers.set(graph, createDisposalOnlyWorker(worker, port)) return } - const client = createPopulationWorkerClient(graph, worker) + const client = createPopulationWorkerClient(graph, worker, port) populationWorkers.set(graph, client) emitTelemetry({ event: 'registered' }) } @@ -56,17 +71,71 @@ export function canUseFigPopulationWorker(graph: SceneGraph): boolean { ) } +export function registerOriginalArchiveRequest( + graph: SceneGraph, + request: () => Promise +): void { + const entry: OriginalArchiveRequest = { request, valid: true, unbind: () => undefined } + const invalidate = () => { + if (!graph.isApplyingLayout) entry.valid = false + } + entry.unbind = graph.onNodeEvents({ + created: invalidate, + updated: invalidate, + deleted: invalidate, + reparented: invalidate, + reordered: invalidate + }) + originalArchiveRequests.set(graph, entry) +} + +export async function requestOriginalArchive(graph: SceneGraph): Promise { + const entry = originalArchiveRequests.get(graph) + if (!entry?.valid) return null + const archive = await entry.request() + return originalArchiveRequests.get(graph)?.valid === true && + originalArchiveRequests.get(graph) === entry + ? archive + : null +} + +export function releaseFigPopulationWorker(graph: SceneGraph): void { + populationWorkers.get(graph)?.terminate() + populationWorkers.delete(graph) + originalArchiveRequests.get(graph)?.unbind() + originalArchiveRequests.delete(graph) +} + export interface FigPopulationWorker { populate: (pageId: string, signal?: AbortSignal) => Promise terminate: () => void } +function createDisposalOnlyWorker(worker: Worker, port: MessagePort): FigPopulationWorker { + let disposed = false + return { + populate: () => Promise.resolve(null), + terminate() { + if (disposed) return + disposed = true + emitTelemetry({ event: 'terminated' }) + port.postMessage({ type: 'dispose' }) + port.close() + worker.terminate() + } + } +} + export function createFigPopulationWorker(graph: SceneGraph): FigPopulationWorker | null { if (!canUseFigPopulationWorker(graph)) return null return populationWorkers.get(graph) ?? null } -function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPopulationWorker { +function createPopulationWorkerClient( + graph: SceneGraph, + worker: Worker, + port?: MessagePort +): FigPopulationWorker { const pending = new Map< string, { @@ -115,8 +184,7 @@ function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPop reparented: invalidate, reordered: invalidate }) - worker.onmessage = (event: MessageEvent) => { - const result = event.data + const receive = (result: WorkerResult) => { if (result.type === 'population-error') return fail() const request = pending.get(result.requestId) if (!request) return @@ -150,6 +218,13 @@ function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPop deleted: result.delta.deleted.length }) } + if (port) { + port.onmessage = (event: MessageEvent) => + receive(event.data as WorkerResult) + port.start() + } else { + worker.onmessage = (event: MessageEvent) => receive(event.data) + } worker.onerror = () => fail() return { populate(pageId, signal) { @@ -175,13 +250,16 @@ function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPop startedAt: performance.now(), timeout }) - worker.postMessage({ type: 'populate', requestId, baseRevision, pageId }, []) + if (port) port.postMessage({ type: 'populate', requestId, baseRevision, pageId }) + else worker.postMessage({ type: 'populate', requestId, baseRevision, pageId }, []) }) }, terminate() { if (disposed) return disposed = true emitTelemetry({ event: 'terminated' }) + port?.postMessage({ type: 'dispose' }) + port?.close() fail(false) } } diff --git a/packages/core/src/kiwi/fig/session/client.ts b/packages/core/src/kiwi/fig/session/client.ts new file mode 100644 index 000000000..63151ddf2 --- /dev/null +++ b/packages/core/src/kiwi/fig/session/client.ts @@ -0,0 +1,4 @@ +export function createFigSessionWorker(): Worker { + if (typeof Worker === 'undefined') throw new Error('FIG session workers are unavailable') + return new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) +} diff --git a/packages/core/src/kiwi/fig/session/original-archive.ts b/packages/core/src/kiwi/fig/session/original-archive.ts new file mode 100644 index 000000000..f037c0703 --- /dev/null +++ b/packages/core/src/kiwi/fig/session/original-archive.ts @@ -0,0 +1,20 @@ +import type { SceneGraph } from '@open-pencil/scene-graph' + +import { requestOriginalArchive } from '#core/kiwi/fig/population/client' + +const originalFigArchives = new WeakMap Promise>() + +export function registerOriginalFigArchive( + graph: SceneGraph, + requestArchive: () => Promise +): void { + originalFigArchives.set(graph, requestArchive) +} + +export async function originalFigArchive(graph: SceneGraph): Promise { + return (await originalFigArchives.get(graph)?.()) ?? (await requestOriginalArchive(graph)) +} + +export function releaseOriginalFigArchive(graph: SceneGraph): void { + originalFigArchives.delete(graph) +} diff --git a/packages/core/src/kiwi/fig/session/protocol.ts b/packages/core/src/kiwi/fig/session/protocol.ts new file mode 100644 index 000000000..ed5ccb717 --- /dev/null +++ b/packages/core/src/kiwi/fig/session/protocol.ts @@ -0,0 +1,54 @@ +import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig' + +import type { FigImportOptions } from '#core/kiwi/fig/import' +import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer' +import type { FigPopulationDelta } from '#core/kiwi/fig/population/delta' + +export interface FigSessionOpenRequest { + type: 'open' + originalBuffer: ArrayBuffer + archiveBuffer: ArrayBuffer + options?: FigImportOptions + port: MessagePort +} + +export interface FigSessionPopulateRequest { + type: 'populate' + requestId: string + baseRevision: number + pageId: string +} + +export interface FigSessionOriginalArchiveRequest { + type: 'original-archive' + requestId: string +} + +export interface FigSessionCancelRequest { + type: 'cancel' + requestId?: string +} + +export interface FigSessionDisposeRequest { + type: 'dispose' +} + +export type FigSessionRequest = + | FigSessionPopulateRequest + | FigSessionOriginalArchiveRequest + | FigSessionCancelRequest + | FigSessionDisposeRequest + +export type FigSessionResponse = + | { type: 'page-manifest'; pages: FigPageManifestEntry[] } + | { type: 'graph'; graph?: SerializedSceneGraph; error?: string } + | { + type: 'population-result' + requestId: string + baseRevision: number + populated: boolean + delta: FigPopulationDelta + } + | { type: 'population-error'; requestId?: string; error: string } + | { type: 'original-archive-result'; requestId: string; bytes: Uint8Array } + | { type: 'disposed' } diff --git a/packages/core/src/kiwi/fig/session/worker.ts b/packages/core/src/kiwi/fig/session/worker.ts new file mode 100644 index 000000000..a1701e890 --- /dev/null +++ b/packages/core/src/kiwi/fig/session/worker.ts @@ -0,0 +1,90 @@ +import { parseFigBuffer } from '@open-pencil/fig' +import type { SceneGraph } from '@open-pencil/scene-graph' + +import { importNodeChanges } from '#core/kiwi/fig/import' +import { getLazyFigImportContext, populateLazyFigImportRoots } from '#core/kiwi/fig/lazy-import' +import { serializeSceneGraph } from '#core/kiwi/fig/parse/transfer' +import { buildFigPopulationDelta, installFigMutationJournal } from '#core/kiwi/fig/population/delta' +import type { + FigSessionOpenRequest, + FigSessionRequest, + FigSessionResponse +} from '#core/kiwi/fig/session/protocol' + +let graph: SceneGraph | undefined +let originalArchive: Uint8Array | undefined +let port: MessagePort | undefined + +function respond(message: FigSessionResponse): void { + port?.postMessage(message) +} + +function populate(request: Extract): void { + if (!graph) throw new Error('FIG session has no retained graph') + const journal = installFigMutationJournal(graph) + try { + const populated = populateLazyFigImportRoots(graph, [request.pageId]) + const context = getLazyFigImportContext(graph) + if (!context) throw new Error('FIG session has no lazy import context') + respond({ + type: 'population-result', + requestId: request.requestId, + baseRevision: request.baseRevision, + populated, + delta: buildFigPopulationDelta(graph, journal, context.populatedRootIds) + }) + } finally { + journal.stop() + } +} + +function handleRequest(request: FigSessionRequest): void { + try { + if (request.type === 'original-archive') { + if (!originalArchive) throw new Error('FIG session has no original archive') + const bytes = originalArchive.slice() + port?.postMessage({ type: 'original-archive-result', requestId: request.requestId, bytes }, [ + bytes.buffer + ]) + return + } + if (request.type === 'dispose') { + graph = undefined + originalArchive = undefined + respond({ type: 'disposed' }) + port?.close() + port = undefined + self.close() + return + } + if (request.type === 'cancel') return + populate(request) + } catch (error) { + respond({ + type: 'population-error', + requestId: request.type === 'populate' ? request.requestId : undefined, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +self.onmessage = (event: MessageEvent) => { + const request = event.data + port = request.port + port.onmessage = (message: MessageEvent) => handleRequest(message.data) + port.start() + originalArchive = new Uint8Array(request.archiveBuffer) + try { + const { nodeChanges, blobs, images, figKiwiVersion, figSchemaDeflated } = parseFigBuffer( + request.originalBuffer, + (pages) => respond({ type: 'page-manifest', pages }) + ) + const parsedGraph = importNodeChanges(nodeChanges, blobs, new Map(images), request.options) + parsedGraph.figKiwiVersion = figKiwiVersion + parsedGraph.figSchemaDeflated = figSchemaDeflated + graph = request.options?.populate === 'first-page' ? parsedGraph : undefined + respond({ type: 'graph', graph: serializeSceneGraph(parsedGraph) }) + } catch (error) { + respond({ type: 'graph', error: error instanceof Error ? error.message : String(error) }) + } +} diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index bb4b0cde6..0db6c9ec6 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -87,6 +87,7 @@ export function createEditorStoreModules( setPlannedFilePath: documentIO.setPlannedFilePath, startWatchingCurrentFile: documentIO.startWatchingCurrentFile, dispose: () => { + editor.releaseGraphResources() editor.dispose() editor.clearPageViewports() documentIO.disposeDocumentIO() diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 61ccc7b4b..b6fe3ae92 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -157,6 +157,7 @@ export async function closeTab(tabId: string): Promise { const wasActive = activeTabId.value === tabId coverThumbnailListeners.get(closingTab.store)?.() coverThumbnailListeners.delete(closingTab.store) + closingTab.store.preparationController.dispose() await closingTab.store.persistRecoveryNow() closingTab.store.dispose() tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId) diff --git a/tests/engine/io/fig/import/session-ownership.test.ts b/tests/engine/io/fig/import/session-ownership.test.ts new file mode 100644 index 000000000..dfa9f0597 --- /dev/null +++ b/tests/engine/io/fig/import/session-ownership.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' + +import { SceneGraph } from '@open-pencil/scene-graph' + +import { + registerFigPopulationWorker, + registerOriginalArchiveRequest, + releaseFigPopulationWorker, + requestOriginalArchive +} from '#core/kiwi/fig/population/client' + +describe('FIG session ownership', () => { + test('discards an archive response when the graph changes while it is pending', async () => { + const graph = new SceneGraph() + let resolveArchive: ((bytes: Uint8Array) => void) | null = null + registerOriginalArchiveRequest( + graph, + () => + new Promise((resolve) => { + resolveArchive = resolve + }) + ) + const request = requestOriginalArchive(graph) + + graph.updateNode(graph.rootId, { name: 'Edited' }) + resolveArchive?.(new Uint8Array([1, 2, 3])) + + await expect(request).resolves.toBeNull() + releaseFigPopulationWorker(graph) + }) + + test('retains and releases oversized session workers', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + for (let index = 0; index < 200_001; index++) { + graph.nodes.set(`oversized-${index}`, { + ...page, + id: `oversized-${index}`, + childIds: [] + }) + } + let workerTerminated = false + let portClosed = false + const worker = { terminate: () => (workerTerminated = true) } as Worker + const port = { + postMessage: () => undefined, + close: () => (portClosed = true) + } as MessagePort + + registerFigPopulationWorker(graph, worker, port) + releaseFigPopulationWorker(graph) + + expect(workerTerminated).toBe(true) + expect(portClosed).toBe(true) + }) + + test.serial('rejects session worker construction outside Worker runtimes', async () => { + const originalWorker = globalThis.Worker + Reflect.deleteProperty(globalThis, 'Worker') + try { + const { createFigSessionWorker } = await import('#core/kiwi/fig/session/client') + expect(() => createFigSessionWorker()).toThrow('unavailable') + } finally { + globalThis.Worker = originalWorker + } + }) +})