perf(fig): show pages before full document decode

This commit is contained in:
Victor Wads 2026-08-17 19:05:39 -03:00
parent 15bd0ba19f
commit 2544b3e4db
No known key found for this signature in database
12 changed files with 414 additions and 28 deletions

View file

@ -50,6 +50,7 @@
### Performance
- Show the FIG page list from a lightweight Kiwi scan before materializing the full document, making large files feel responsive sooner.
- Use cached axis-aligned world positions for hit testing untransformed layer chains and add representative 500/2,000-node interaction profiles. (#527)
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)

View file

@ -1,4 +1,5 @@
import { parseFigBuffer } from '@open-pencil/fig'
import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { IS_BROWSER } from '#core/constants'
@ -9,6 +10,7 @@ import { registerFigPopulationWorker } from '#core/kiwi/fig/population/client'
export interface ParseFigFileOptions {
populate?: 'all' | 'first-page' | 'none'
onPages?: (pages: readonly FigPageManifestEntry[]) => void
}
function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph {
@ -18,18 +20,26 @@ function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}
images: imageEntries,
figKiwiVersion,
figSchemaDeflated
} = parseFigBuffer(buffer)
} = parseFigBuffer(buffer, options.onPages)
const graph = importNodeChanges(nodeChanges, blobs, new Map(imageEntries), options)
graph.figKiwiVersion = figKiwiVersion
graph.figSchemaDeflated = figSchemaDeflated
return graph
}
interface WorkerParseResult {
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<SceneGraph> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('../../../kiwi/fig/parse/worker.ts', import.meta.url), {
@ -37,6 +47,10 @@ function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Prom
})
worker.onmessage = (e: MessageEvent<WorkerParseResult>) => {
if (e.data.type === 'page-manifest') {
options.onPages?.(e.data.pages)
return
}
if (e.data.error || !e.data.graph) {
worker.terminate()
reject(new Error(e.data.error ?? 'Worker failed to parse .fig file'))
@ -61,7 +75,7 @@ function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Prom
reject(new Error(err.message || 'Worker failed to parse .fig file'))
}
worker.postMessage({ buffer, options }, [buffer])
worker.postMessage({ buffer, options: { populate: options.populate } }, [buffer])
})
}

View file

@ -58,7 +58,8 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
const parseRequest: WorkerParseRequest =
request instanceof ArrayBuffer ? { buffer: request } : request
const { nodeChanges, blobs, images, figKiwiVersion, figSchemaDeflated } = parseFigBuffer(
parseRequest.buffer
parseRequest.buffer,
(pages) => postWorkerMessage({ type: 'page-manifest', pages }, [])
)
const parsedGraph = importNodeChanges(nodeChanges, blobs, new Map(images), parseRequest.options)
parsedGraph.figKiwiVersion = figKiwiVersion
@ -69,13 +70,13 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
parseRequest.options?.populate === 'first-page'
? []
: serializedSceneGraphTransferList(serialized)
postWorkerMessage({ graph: serialized }, transfer)
postWorkerMessage({ type: 'graph', graph: serialized }, transfer)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
postWorkerMessage(
isPopulateRequest(request)
? { type: 'population-error', error: errorMessage }
: { error: errorMessage },
: { type: 'graph', error: errorMessage },
[]
)
}

View file

@ -1,5 +1,6 @@
import { unzipSync, zipSync, type Zippable } from 'fflate'
import { unzipSync, zipSync, type Unzipped, type Zippable } from 'fflate'
import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { buildFigKiwi } from '@open-pencil/kiwi/fig/container'
import { decodeFigKiwiCanvas } from '@open-pencil/kiwi/fig/parse'
@ -46,17 +47,34 @@ function findCanvasData(entries: Partial<Record<string, Uint8Array>>): Uint8Arra
return largest
}
function isCanonicalCanvasEntry(name: string): boolean {
return name === 'canvas.fig' || name === 'canvas'
}
/** Parse a complete zipped `.fig` file into its Figma protocol payload and binary resources. */
export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult {
const archive = unzipSync(new Uint8Array(buffer))
const canvasData = findCanvasData(archive)
if (!canvasData) {
throw new Error(
`No canvas data found in .fig file. Entries: ${Object.keys(archive).join(', ')}`
)
export function parseFigBuffer(
buffer: ArrayBuffer,
onPages?: (pages: FigPageManifestEntry[]) => void
): FigParseResult {
const bytes = new Uint8Array(buffer)
const canvasArchive = unzipSync(bytes, { filter: ({ name }) => isCanonicalCanvasEntry(name) })
let canvasData = findCanvasData(canvasArchive)
let archive: Unzipped
let decoded: ReturnType<typeof decodeFigKiwiCanvas>
if (canvasData) {
decoded = decodeFigKiwiCanvas(canvasData, onPages)
archive = unzipSync(bytes, { filter: ({ name }) => !isCanonicalCanvasEntry(name) })
} else {
archive = unzipSync(bytes)
canvasData = findCanvasData(archive)
if (!canvasData) {
throw new Error(
`No canvas data found in .fig file. Entries: ${Object.keys(archive).join(', ')}`
)
}
decoded = decodeFigKiwiCanvas(canvasData, onPages)
}
const decoded = decodeFigKiwiCanvas(canvasData)
const metaBytes = archive['meta.json']
const images = Object.entries(archive)
.filter(([name]) => name.startsWith('images/') && name !== 'images/')

View file

@ -1,4 +1,5 @@
export { default as figmaSchema } from './schema'
export * from './page-manifest'
export * from './protocol'
export * from './types'
export * from './variable-bindings'

View file

@ -0,0 +1,246 @@
import { ByteBuffer } from '../schema-runtime/bb'
import type { Definition, Field, Schema } from '../schema-runtime/schema'
export interface FigPageManifestEntry {
sourceId: string
name: string
position: string | null
internalOnly: boolean
}
interface PageCandidate extends FigPageManifestEntry {
parentId: string | null
phase: string | null
type: string | null
}
type DefinitionMap = Map<string, Definition>
interface ScanContext {
definitions: DefinitionMap
fields: Map<string, Map<number, Field>>
}
function fieldMap(definition: Definition): Map<number, Field> {
return new Map(definition.fields.map((field) => [field.value, field]))
}
function cachedFieldMap(context: ScanContext, definition: Definition): Map<number, Field> {
let fields = context.fields.get(definition.name)
if (!fields) {
fields = fieldMap(definition)
context.fields.set(definition.name, fields)
}
return fields
}
function skipSingleValue(bb: ByteBuffer, field: Field, context: ScanContext): void {
switch (field.type) {
case 'bool':
case 'byte':
bb.readByte()
return
case 'int':
bb.readVarInt()
return
case 'uint':
bb.readVarUint()
return
case 'float':
bb.readVarFloat()
return
case 'string':
bb.skipString()
return
case 'int64':
bb.readVarInt64()
return
case 'uint64':
bb.readVarUint64()
return
}
const definition = field.type ? context.definitions.get(field.type) : undefined
if (!definition) throw new Error(`Invalid Kiwi field type: ${String(field.type)}`)
if (definition.kind === 'ENUM') {
bb.readVarUint()
return
}
skipDefinition(bb, definition, context)
}
function skipFieldValue(bb: ByteBuffer, field: Field, context: ScanContext): void {
if (!field.isArray) {
skipSingleValue(bb, field, context)
return
}
if (field.type === 'byte') {
bb.skipByteArray()
return
}
let length = bb.readVarUint()
while (length-- > 0) skipSingleValue(bb, field, context)
}
function skipDefinition(bb: ByteBuffer, definition: Definition, context: ScanContext): void {
if (definition.kind === 'STRUCT') {
for (const field of definition.fields) skipFieldValue(bb, field, context)
return
}
const fields = cachedFieldMap(context, definition)
for (;;) {
const fieldId = bb.readVarUint()
if (fieldId === 0) return
const field = fields.get(fieldId)
if (!field) throw new Error(`Invalid field ${fieldId} in Kiwi ${definition.name}`)
skipFieldValue(bb, field, context)
}
}
function enumName(bb: ByteBuffer, field: Field, context: ScanContext): string | null {
const value = bb.readVarUint()
const definition = field.type ? context.definitions.get(field.type) : undefined
return definition?.kind === 'ENUM'
? (definition.fields.find((candidate) => candidate.value === value)?.name ?? null)
: null
}
function scanNodeChange(
bb: ByteBuffer,
definition: Definition,
context: ScanContext
): PageCandidate | null {
const fields = cachedFieldMap(context, definition)
let sourceSessionId: number | null = null
let sourceLocalId: number | null = null
let parentSessionId: number | null = null
let parentLocalId: number | null = null
let positionOffset: number | null = null
let phase: string | null = null
let type: string | null = null
let name: string | null = null
let internalOnly = false
for (;;) {
const fieldId = bb.readVarUint()
if (fieldId === 0) break
const field = fields.get(fieldId)
if (!field) throw new Error(`Invalid field ${fieldId} in Kiwi NodeChange`)
switch (field.name) {
case 'guid':
sourceSessionId = bb.readVarUint()
sourceLocalId = bb.readVarUint()
break
case 'parentIndex':
parentSessionId = bb.readVarUint()
parentLocalId = bb.readVarUint()
positionOffset = bb.offset
bb.skipString()
break
case 'phase':
phase = enumName(bb, field, context)
break
case 'type':
type = enumName(bb, field, context)
break
case 'name':
if (type === 'DOCUMENT' || type === 'CANVAS') name = bb.readString()
else bb.skipString()
break
case 'internalOnly':
internalOnly = !!bb.readByte()
break
default:
skipFieldValue(bb, field, context)
}
}
if (
(type !== 'DOCUMENT' && type !== 'CANVAS') ||
sourceSessionId === null ||
sourceLocalId === null
) {
return null
}
let position: string | null = null
if (positionOffset !== null) {
const endOffset = bb.offset
bb.offset = positionOffset
position = bb.readString()
bb.offset = endOffset
}
const parentId =
parentSessionId === null || parentLocalId === null
? null
: `${parentSessionId}:${parentLocalId}`
return {
sourceId: `${sourceSessionId}:${sourceLocalId}`,
parentId,
position,
phase,
type,
name: name ?? 'Page',
internalOnly
}
}
/**
* Scans decoded Kiwi bytes without materializing NodeChange objects. Kiwi messages are not
* length-prefixed, so every value is visited, but only document/page fields are allocated.
*/
export function extractFigPageManifest(schema: Schema, data: Uint8Array): FigPageManifestEntry[] {
const definitions: DefinitionMap = new Map(
schema.definitions.map((definition) => [definition.name, definition])
)
const context: ScanContext = { definitions, fields: new Map() }
const message = definitions.get('Message')
const nodeChange = definitions.get('NodeChange')
if (message?.kind !== 'MESSAGE' || nodeChange?.kind !== 'MESSAGE') {
return []
}
const bb = new ByteBuffer(data)
const messageFields = cachedFieldMap(context, message)
const candidates: PageCandidate[] = []
for (;;) {
const fieldId = bb.readVarUint()
if (fieldId === 0) break
const field = messageFields.get(fieldId)
if (!field) throw new Error(`Invalid field ${fieldId} in Kiwi Message`)
if (field.name !== 'nodeChanges' || !field.isArray) {
skipFieldValue(bb, field, context)
continue
}
let length = bb.readVarUint()
while (length-- > 0) {
const candidate = scanNodeChange(bb, nodeChange, context)
if (candidate) candidates.push(candidate)
}
break
}
const documentId = candidates.find(
(candidate) => candidate.type === 'DOCUMENT' && candidate.phase !== 'REMOVED'
)?.sourceId
return candidates
.filter(
(candidate) =>
candidate.type === 'CANVAS' &&
candidate.phase !== 'REMOVED' &&
(!documentId || candidate.parentId === documentId)
)
.sort((a, b) => {
const aPosition = a.position ?? ''
const bPosition = b.position ?? ''
if (aPosition < bPosition) return -1
if (aPosition > bPosition) return 1
return 0
})
.map(({ sourceId, name, position, internalOnly }) => ({
sourceId,
name,
position,
internalOnly
}))
}

View file

@ -3,6 +3,7 @@ import { decompress as zstdDecompress } from 'fzstd'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '../schema-runtime'
import type { FigmaMessage, NodeChange } from './codec'
import { extractFigPageManifest, type FigPageManifestEntry } from './page-manifest'
import { isZstdCompressed } from './protocol'
export type { NodeChange } from './codec'
@ -93,12 +94,23 @@ export interface FigKiwiDecodeResult {
}
/** Decode one raw `fig-kiwi` canvas payload. Outer `.fig` archive handling lives in `@open-pencil/fig`. */
export function decodeFigKiwiCanvas(data: Uint8Array): FigKiwiDecodeResult {
export function decodeFigKiwiCanvas(
data: Uint8Array,
onPages?: (pages: FigPageManifestEntry[]) => void
): FigKiwiDecodeResult {
const payload = parseFigKiwiContainer(data)
if (!payload) throw new Error('Invalid fig-kiwi container')
const schemaBytes = inflateSync(payload.schemaDeflated)
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
if (onPages) {
try {
const pages = extractFigPageManifest(schema, payload.dataRaw)
if (pages.length > 0) onPages(pages)
} catch (error) {
console.warn('Failed to scan FIG page manifest; continuing with full decode:', error)
}
}
const compiled = compileSchema(schema) as CompiledKiwiSchema
const message = compiled.decodeMessage(payload.dataRaw) as FigmaMessage

View file

@ -16,6 +16,14 @@ export class ByteBuffer {
this.length = data ? data.length : 0
}
get offset(): number {
return this._index
}
set offset(value: number) {
this._index = value
}
/**
* Returns a view into the internal buffer, not a copy.
*
@ -39,6 +47,11 @@ export class ByteBuffer {
return this._data.slice(start, start + length)
}
skipByteArray(): void {
const length = this.readVarUint()
this._index += length
}
readVarFloat(): number {
const index = this._index
const data = this._data
@ -123,6 +136,12 @@ export class ByteBuffer {
return textDecoder.decode(data.subarray(start, i))
}
skipString(): void {
while (this._data[this._index++] !== 0) {
// Strings are null-terminated in Kiwi messages.
}
}
private _growBy(amount: number): void {
if (this.length + amount > this._data.length) {
let data = new Uint8Array((this.length + amount) << 1)

View file

@ -0,0 +1,29 @@
import type { Editor } from '@open-pencil/core/editor'
import { readFigFile } from '@open-pencil/core/io/formats/fig'
import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig'
import { SceneGraph } from '@open-pencil/scene-graph'
/** Show lightweight page shells while the FIG worker continues decoding the full document. */
export function showFigPageManifest(editor: Editor, pages: readonly FigPageManifestEntry[]): void {
if (pages.length === 0) return
const graph = new SceneGraph()
for (const page of graph.getPages(true)) graph.deleteNode(page.id)
for (const entry of pages) {
const page = graph.addPage(entry.name)
page.internalOnly = entry.internalOnly
page.source.format = 'fig'
page.source.id = entry.sourceId
page.source.orderKey = entry.position
}
editor.replaceGraph(graph)
editor.state.loading = true
}
export function readFigFileProgressively(file: File, editor: Editor) {
return readFigFile(file, {
populate: 'first-page',
onPages: (pages) => showFigPageManifest(editor, pages)
})
}

View file

@ -1,8 +1,8 @@
import type { Editor, EditorState } from '@open-pencil/core/editor'
import { readFigFile } from '@open-pencil/core/io/formats/fig'
import { computeAllLayouts } from '@open-pencil/core/layout'
import { yieldToUI } from '@/app/document/io/browser'
import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest'
import { applyImportedDocument } from '@/app/document/io/imported-document'
import { readReloadSource } from '@/app/document/io/reload-source'
import { captureReloadState, restoreReloadState } from '@/app/document/io/reload-state'
@ -45,7 +45,7 @@ export function createOpenActions({
try {
state.loading = true
await yieldToUI()
const imported = await readFigFile(file, { populate: 'first-page' })
const imported = await readFigFileProgressively(file, editor)
await yieldToUI()
await applyImportedDocument(editor, imported)
state.documentName = file.name.replace(/\.fig$/i, '')

View file

@ -1,11 +1,11 @@
import { shallowRef, computed, triggerRef } from 'vue'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import { readFigFile } from '@open-pencil/core/io/formats/fig'
import { computeAllLayouts } from '@open-pencil/core/layout'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { setOpenPencilStore } from '@/app/browser-bridge'
import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest'
import type { DocumentSourceIdentity } from '@/app/document/io/types'
import { getRecoveryStore, type RecoverySnapshotMeta } from '@/app/document/recovery'
import { setActiveEditorStore } from '@/app/editor/active-store'
@ -129,6 +129,13 @@ function reusableTabStore(): EditorStore {
return isUntouched ? current.store : createTab().store
}
async function readFigForTab(file: File, store: EditorStore): Promise<SceneGraph> {
const imported = await readFigFileProgressively(file, store)
const firstPageId = imported.getPages()[0]?.id
if (firstPageId) computeAllLayouts(imported, firstPageId)
return imported
}
function findStorageTab(providerId: string, documentId: string): Tab | undefined {
return tabsRef.value.find((tab) => {
const binding = tab.store.getStorageBinding()
@ -173,9 +180,7 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr
const file = new File([fileBytes.buffer], `${document.name}.fig`, {
type: 'application/octet-stream'
})
const imported = await readFigFile(file, { populate: 'first-page' })
const firstPageId = imported.getPages()[0]?.id
if (firstPageId) computeAllLayouts(imported, firstPageId)
const imported = await readFigForTab(file, store)
store.replaceGraph(imported)
store.undo.clear()
store.setStorageDocumentSource({ providerId, documentId: document.id }, document.name)
@ -239,7 +244,10 @@ export async function openFileInNewTab(
await yieldToUI()
const isFig = file.name.toLowerCase().endsWith('.fig')
const { graph: imported, sourceFormat } = isFig
? { graph: await readFigFile(file, { populate: 'first-page' }), sourceFormat: 'fig' }
? {
graph: await readFigForTab(file, store),
sourceFormat: 'fig'
}
: await io.readDocument({
name: file.name,
mimeType: file.type || undefined,
@ -247,7 +255,7 @@ export async function openFileInNewTab(
})
const firstPageId = imported.getPages()[0]?.id
if (firstPageId) computeAllLayouts(imported, firstPageId)
if (!isFig && firstPageId) computeAllLayouts(imported, firstPageId)
store.replaceGraph(imported)
store.undo.clear()
store.setDocumentSource(file.name, sourceFormat, handle, path)
@ -277,15 +285,13 @@ export async function restoreRecoverySnapshot(id: string): Promise<void> {
const snapshot = await getRecoveryStore().read(id)
if (!snapshot) throw new Error('Recovery snapshot is no longer available')
const store = reusableTabStore()
const fileBytes = new Uint8Array(snapshot.figBytes)
const file = new File([fileBytes.buffer], `${snapshot.documentName}.fig`, {
type: 'application/octet-stream'
})
const imported = await readFigFile(file, { populate: 'first-page' })
const firstPageId = imported.getPages()[0]?.id
if (firstPageId) computeAllLayouts(imported, firstPageId)
const imported = await readFigForTab(file, store)
const store = reusableTabStore()
store.replaceGraph(imported)
store.undo.clear()
store.state.documentName = snapshot.documentName

View file

@ -0,0 +1,39 @@
import { expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { parseFigBuffer } from '@open-pencil/fig'
import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig'
const fixturePath = resolve(import.meta.dir, '../../../fixtures/gold-preview.fig')
test('reports FIG pages before materializing NodeChange objects', () => {
const bytes = readFileSync(fixturePath)
let parseCompleted = false
let manifest: FigPageManifestEntry[] = []
const parsed = parseFigBuffer(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
(pages) => {
expect(parseCompleted).toBe(false)
manifest = pages
}
)
parseCompleted = true
expect(parsed.nodeChanges.length).toBeGreaterThan(0)
expect(manifest).toEqual([
{
sourceId: '0:1',
name: 'Page 1',
position: '!',
internalOnly: false
},
{
sourceId: '0:2',
name: 'Internal Only Canvas',
position: '~',
internalOnly: true
}
])
})