perf(scene-graph): give imported nodes the shape every node shares (#903)
* perf(scene-graph): give imported nodes the shape every node shares createDefaultNode had no default for booleanOperation, and the .fig importer sets that key on every node. A key outside an object's initial shape turns it into a JavaScriptCore dictionary, so every imported node had its own structure and dictionary property storage. Fully loading material3.fig took 3.7 GB RSS in Bun; with the default it takes 2.8 GB, with 0.5 GB less JS heap and 0.46 GB less memory outside it. V8 keeps these objects fast either way. The defaults now must name every SceneNode field, which the compiler enforces, and a test checks that imported nodes carry exactly the default fields. booleanOperation states the explicit undefined the importer already stores. * fix(scene-graph): require booleanOperation and compare node keys in order Every SceneNode field is required except booleanOperation, so a typed producer could still omit it and build a node with a different shape. It is now required, with undefined still meaning no operation. The node-shape test compares keys in insertion order, since JavaScriptCore lays out the same keys added in another order as a different shape. The changelog entry no longer quotes the Bun measurement as figures for the macOS app and Safari, and records the type change as breaking.
This commit is contained in:
parent
09accf78df
commit
9c28c13fdf
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Breaking changes
|
||||
|
||||
- `SceneNode.booleanOperation` is a required key whose value may be `undefined`, like every other scene node field, so code that builds `SceneNode` objects itself must include it.
|
||||
- `sceneNodeToDesignDocument` from `@open-pencil/dom-css` takes an options object, `{ includeSourceIds, tokens }`, instead of a boolean third argument; `tokens: false` writes literal values instead of variable references.
|
||||
- `randomHex`, `randomInt`, and `randomIndex` moved from `@open-pencil/core/random` and the `@open-pencil/core` barrel to `@open-pencil/scene-graph/random`.
|
||||
- `Stroke` from `@open-pencil/scene-graph` extends `Fill`, so every stroke states a paint `type` that code constructing one must set to `'SOLID'`, and `copyStroke` deep-copies the paint fields a fill already copied.
|
||||
|
|
@ -144,6 +145,7 @@
|
|||
|
||||
### Performance
|
||||
|
||||
- Open large `.fig` files with less memory in the macOS desktop app and Safari: imported layers now share one object layout in JavaScriptCore instead of each being stored as a slower, larger dictionary.
|
||||
- Open multi-page `.fig` documents faster: the archive is indexed once rather than once for every page, each page resolves only the layers it adds instead of rescanning the whole document, placing an instance no longer re-synchronises every other instance of its component, and archive records are copied directly rather than through `structuredClone`. A 33-page file loads about a fifth quicker, and a page of repeated components opens three to four times faster once a document is already open.
|
||||
|
||||
### Security
|
||||
|
|
|
|||
23
packages/fig/tests/document/node-shape.test.ts
Normal file
23
packages/fig/tests/document/node-shape.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { expect, test } from 'bun:test'
|
||||
|
||||
import { readFixtureArrayBuffer } from '#fig-tests/helpers/fig-fixtures'
|
||||
|
||||
import { createFigDocumentSession } from '@open-pencil/fig'
|
||||
import { createDefaultNode } from '@open-pencil/scene-graph/node-defaults'
|
||||
|
||||
// Imported nodes must keep the shape every node is created with: a key outside it turns each
|
||||
// node into a JavaScriptCore dictionary, which made opening large files in WebKit take about a
|
||||
// fifth more memory.
|
||||
test('imported nodes carry exactly the default scene node fields', () => {
|
||||
const session = createFigDocumentSession(readFixtureArrayBuffer('gold-preview.fig'))
|
||||
for (const page of session.pages) if (!page.internalOnly) session.loadPage(page.id)
|
||||
// Insertion order matters too: JavaScriptCore lays out the same keys added in another order
|
||||
// as a different shape.
|
||||
const fields = Object.keys(createDefaultNode(() => 'node', 'FRAME'))
|
||||
const shapes = new Map<string, string>()
|
||||
for (const node of session.graph.nodes.values()) {
|
||||
const keys = Object.keys(node).join(',')
|
||||
if (!shapes.has(keys)) shapes.set(keys, node.id)
|
||||
}
|
||||
expect([...shapes.keys()].map((keys) => keys.split(','))).toEqual([fields])
|
||||
})
|
||||
|
|
@ -22,6 +22,13 @@ export function createDefaultSourceMetadata(): SourceMetadata {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every SceneNode field. Nodes start with all of them, so setting any field later keeps the
|
||||
* shape every node shares; a key added after creation turns a JavaScriptCore object into a
|
||||
* slower, larger dictionary.
|
||||
*/
|
||||
type CompleteNodeFields = SceneNode & Record<keyof SceneNode, unknown>
|
||||
|
||||
export function createDefaultNode(
|
||||
generateId: () => string,
|
||||
type: NodeType,
|
||||
|
|
@ -173,8 +180,9 @@ export function createDefaultNode(
|
|||
derivedTextGlyphs: null,
|
||||
textPathData: null,
|
||||
textPathBox: null,
|
||||
booleanOperation: undefined,
|
||||
...overrides
|
||||
}
|
||||
} satisfies CompleteNodeFields
|
||||
}
|
||||
|
||||
export const CONTAINER_TYPES = new Set<NodeType>([
|
||||
|
|
|
|||
|
|
@ -504,7 +504,7 @@ export interface SceneNode {
|
|||
|
||||
vectorNetwork: VectorNetwork | null
|
||||
handleMirroring: HandleMirroring
|
||||
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
|
||||
booleanOperation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE' | undefined
|
||||
fillGeometry: GeometryPath[]
|
||||
strokeGeometry: GeometryPath[]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue