perf(fig): speed up large page switches (#428)

* perf(fig): scope lazy page population work

- Limit override propagation and clone indexing to active page nodes
- Reuse clone membership sets instead of rebuilding them per subtree
- Coalesce Layers tree structural rebuilds during population
- Add a regression test that rejects document-wide scans

* fix(fig): preserve global override propagation

- Keep fill and child-placement propagation across clone lineages
- Retain scoped work only for passes proven independent of inactive pages
- Cover the two required global propagation scans

* perf(fig): finish scoped population review

- Stream active override candidates without temporary arrays
- Scope fallback clone maps when no lineage index is supplied
- Skip stale queued Layers tree rebuilds after synchronous refreshes
This commit is contained in:
Danila Poyarkov 2026-07-28 00:12:53 +03:00 committed by GitHub
parent a7a44e90b2
commit fc8bccac7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 153 additions and 50 deletions

View file

@ -4,6 +4,7 @@
### Fixed
- Reduce large `.fig` page-switch work to the active page and coalesce Layers tree rebuilds. (#420)
- Center text glyphs within explicit line-height leading in CanvasKit paragraph rendering.
### Added

View file

@ -3,6 +3,7 @@ import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
import { buildClonesMap } from './sync'
import type { OverrideContext } from './types'
import { overrideCandidates } from './utils'
/**
* Apply SCALE constraint resizing to children of instances whose size
@ -13,8 +14,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
const { graph } = ctx
const scaled = new Set<string>()
for (const node of graph.getAllNodes()) {
if (ctx.activeNodeIds && !ctx.activeNodeIds.has(node.id)) continue
for (const node of overrideCandidates(graph, ctx.activeNodeIds)) {
if (node.type !== 'INSTANCE' || !node.componentId) continue
const comp = graph.getNode(node.componentId)
if (!comp || comp.width <= 0 || comp.height <= 0) continue
@ -197,8 +197,7 @@ function scaleChildren(
function normalizeOutOfBoundsSingleChildren(ctx: OverrideContext): void {
const { graph } = ctx
for (const parent of graph.getAllNodes()) {
if (ctx.activeNodeIds && !ctx.activeNodeIds.has(parent.id)) continue
for (const parent of overrideCandidates(graph, ctx.activeNodeIds)) {
if (parent.childIds.length !== 1) continue
const child = graph.getNode(parent.childIds[0])
if (!child?.visible || !child.componentId) continue

View file

@ -35,6 +35,7 @@ import { applySymbolOverrides } from './symbol/overrides'
import { propagateNodePropsTransitively, propagateOverridesTransitively } from './sync'
import { indexCloneNodes } from './sync/sources'
import type { InstanceNodeChange, OverrideContext, ComponentPropValue } from './types'
import { overrideCandidates } from './utils'
/**
* Identify nodes whose kiwi NC has explicit property values that DIFFER
@ -84,15 +85,10 @@ function buildKiwiGeometryNodes(
return result
}
function propagateResolvedFills(
graph: SceneGraph,
protectedNodes: Set<string>,
activeNodeIds?: Set<string>
): void {
function propagateResolvedFills(graph: SceneGraph, protectedNodes: Set<string>): void {
for (let pass = 0; pass < 10; pass++) {
let changed = false
for (const node of graph.getAllNodes()) {
if (activeNodeIds && !activeNodeIds.has(node.id)) continue
if (!node.componentId) continue
const source = graph.getNode(node.componentId)
if (!source || isEqual(source.fills, node.fills)) continue
@ -144,7 +140,7 @@ function sameDerivedGlyphSource(
return hasSameCopySource(source, target)
}
function propagateResolvedTextClones(graph: SceneGraph): void {
function propagateResolvedTextClones(graph: SceneGraph, activeNodeIds?: Set<string>): void {
const ordered: SceneNode[] = []
const visited = new Set<string>()
const visiting = new Set<string>()
@ -157,7 +153,7 @@ function propagateResolvedTextClones(graph: SceneGraph): void {
visited.add(node.id)
if (node.type === 'TEXT' && node.componentId) ordered.push(node)
}
for (const nodeId of graph.nodes.keys()) {
for (const nodeId of activeNodeIds ?? graph.nodes.keys()) {
const node = graph.getNode(nodeId)
if (node?.type === 'TEXT' && node.componentId) visit(node)
}
@ -326,12 +322,8 @@ export function populateAndApplyOverrides(
}
applyDerivedSymbolData(ctx)
propagateResolvedFills(
graph,
new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes]),
ctx.activeNodeIds
)
propagateResolvedTextClones(graph)
propagateResolvedFills(graph, new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes]))
propagateResolvedTextClones(graph, ctx.activeNodeIds)
applyConstraintScaling(ctx)
applyComponentProperties(ctx)

View file

@ -9,6 +9,7 @@ import {
snapshotChildSources
} from './sync/sources'
import type { InstanceNodeChange, OverrideContext } from './types'
import { overrideCandidates } from './utils'
const MAX_CHAIN_DEPTH = 20
const siblingIndexCache = new WeakMap<OverrideContext, Map<string, number | null>>()
@ -41,7 +42,7 @@ export function preComputeRoots(ctx: OverrideContext): void {
return nodeId
}
for (const node of ctx.graph.getAllNodes()) {
for (const node of overrideCandidates(ctx.graph, ctx.activeNodeIds)) {
if (!node.componentId) continue
resolve(node.id)
const clones = ctx.preComputedClones.get(node.componentId)
@ -484,7 +485,13 @@ export function repopulateInstance(ctx: OverrideContext, nodeId: string, compId:
indexCloneSubtree(ctx.graph, nodeId, ctx.preComputedClones)
applyStrokeDescendants(ctx, nodeId, previousStrokes)
}
remapRepopulatedChildSources(ctx.graph, nodeId, previousSources, ctx.preComputedClones)
remapRepopulatedChildSources(
ctx.graph,
nodeId,
previousSources,
ctx.preComputedClones,
ctx.activeNodeIds
)
ctx.swappedInstances.add(nodeId)
ctx.componentIdRoot.clear()
candidateCache.delete(ctx)

View file

@ -1,6 +1,7 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { ProtectionMap } from '../patches'
import { overrideCandidates } from '../utils'
import { syncNodeProps } from './fields'
import { indexCloneSubtree, remapRepopulatedChildSources, snapshotChildSources } from './sources'
@ -10,11 +11,12 @@ export function recloneChildren(
tgtNode: SceneNode,
swappedInstances: Set<string>,
protections?: ProtectionMap,
cloneSources?: Map<string, string[]>
cloneSources?: Map<string, string[]>,
activeNodeIds?: Set<string>
): void {
const srcChild = graph.getNode(srcChildId)
if (!srcChild) return
const effectiveCloneSources = cloneSources ?? buildClonesMap(graph)
const effectiveCloneSources = cloneSources ?? buildClonesMap(graph, activeNodeIds)
const previousSources = snapshotChildSources(graph, tgtNode.id)
for (const childId of Array.from(tgtNode.childIds)) graph.deleteNode(childId)
@ -24,7 +26,13 @@ export function recloneChildren(
graph.populateInstanceChildren(tgtNode.id, srcChildId, 'fig-import')
indexCloneSubtree(graph, tgtNode.id, effectiveCloneSources)
}
remapRepopulatedChildSources(graph, tgtNode.id, previousSources, effectiveCloneSources)
remapRepopulatedChildSources(
graph,
tgtNode.id,
previousSources,
effectiveCloneSources,
activeNodeIds
)
swappedInstances.add(tgtNode.id)
}
@ -35,12 +43,13 @@ export function syncChildrenDeep(
swappedInstances: Set<string>,
skip?: Set<string>,
protections?: ProtectionMap,
cloneSources?: Map<string, string[]>
cloneSources?: Map<string, string[]>,
activeNodeIds?: Set<string>
): void {
const src = graph.getNode(sourceId)
const tgt = graph.getNode(targetId)
if (!src || !tgt) return
const effectiveCloneSources = cloneSources ?? buildClonesMap(graph)
const effectiveCloneSources = cloneSources ?? buildClonesMap(graph, activeNodeIds)
const len = Math.min(src.childIds.length, tgt.childIds.length)
for (let i = 0; i < len; i++) {
if (skip?.has(tgt.childIds[i])) continue
@ -55,7 +64,8 @@ export function syncChildrenDeep(
tgtNode,
swappedInstances,
protections,
effectiveCloneSources
effectiveCloneSources,
activeNodeIds
)
continue
}
@ -68,7 +78,8 @@ export function syncChildrenDeep(
swappedInstances,
skip,
protections,
effectiveCloneSources
effectiveCloneSources,
activeNodeIds
)
}
}
@ -78,8 +89,7 @@ export function buildClonesMap(
activeNodeIds?: Set<string>
): Map<string, string[]> {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (activeNodeIds && !activeNodeIds.has(node.id)) continue
for (const node of overrideCandidates(graph, activeNodeIds)) {
if (!node.componentId) continue
let arr = clonesOf.get(node.componentId)
if (!arr) {

View file

@ -141,9 +141,18 @@ export function propagateOverridesTransitively(
graph.populateInstanceChildren(node.id, sourceId, 'fig-import')
indexCloneSubtree(graph, node.id, clonesOf)
}
remapRepopulatedChildSources(graph, node.id, previousSources, clonesOf)
remapRepopulatedChildSources(graph, node.id, previousSources, clonesOf, activeNodeIds)
} else if (source.childIds.length > 0 && node.childIds.length > 0) {
syncChildrenDeep(graph, sourceId, node.id, swappedInstances, skip, protections, clonesOf)
syncChildrenDeep(
graph,
sourceId,
node.id,
swappedInstances,
skip,
protections,
clonesOf,
activeNodeIds
)
}
syncQueue.push(cloneId)
}

View file

@ -1,5 +1,7 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { overrideCandidates } from '../utils'
interface ChildSourceSnapshot {
id: string
path: number[]
@ -7,15 +9,27 @@ interface ChildSourceSnapshot {
}
const refreshedCloneSourceMaps = new WeakSet<Map<string, string[]>>()
const cloneSourceIdCaches = new WeakMap<Map<string, string[]>, Map<string, Set<string>>>()
function cloneSourceIds(cloneSources: Map<string, string[]>): Map<string, Set<string>> {
let cached = cloneSourceIdCaches.get(cloneSources)
if (!cached) {
cached = new Map([...cloneSources].map(([sourceId, cloneIds]) => [sourceId, new Set(cloneIds)]))
cloneSourceIdCaches.set(cloneSources, cached)
}
return cached
}
// SceneGraph.instanceIndex contains INSTANCE nodes only, while generated text,
// frame, and vector descendants also use componentId as clone provenance.
function refreshCloneSources(graph: SceneGraph, cloneSources: Map<string, string[]>): void {
function refreshCloneSources(
graph: SceneGraph,
cloneSources: Map<string, string[]>,
activeNodeIds?: Set<string>
): void {
if (refreshedCloneSourceMaps.has(cloneSources)) return
const knownIds = new Map(
[...cloneSources].map(([sourceId, cloneIds]) => [sourceId, new Set(cloneIds)])
)
for (const node of graph.getAllNodes()) {
const knownIds = cloneSourceIds(cloneSources)
for (const node of overrideCandidates(graph, activeNodeIds)) {
if (!node.componentId) continue
let known = knownIds.get(node.componentId)
if (!known) {
@ -35,13 +49,13 @@ export function indexCloneNodes(
nodeIds: Iterable<string>,
cloneSources: Map<string, string[]>
): void {
const knownIds = new Map<string, Set<string>>()
const knownIds = cloneSourceIds(cloneSources)
for (const nodeId of nodeIds) {
const node = graph.getNode(nodeId)
if (!node?.componentId) continue
let known = knownIds.get(node.componentId)
if (!known) {
known = new Set(cloneSources.get(node.componentId))
known = new Set()
knownIds.set(node.componentId, known)
}
if (known.has(node.id)) continue
@ -107,9 +121,10 @@ export function remapRepopulatedChildSources(
graph: SceneGraph,
parentId: string,
previousSources: ChildSourceSnapshot[],
cloneSources?: Map<string, string[]>
cloneSources?: Map<string, string[]>,
activeNodeIds?: Set<string>
): void {
if (cloneSources) refreshCloneSources(graph, cloneSources)
if (cloneSources) refreshCloneSources(graph, cloneSources, activeNodeIds)
for (const previous of previousSources) {
const replacement = resolveChildPath(graph, parentId, previous.path)
if (!replacement || replacement.type !== previous.type) continue
@ -122,10 +137,20 @@ export function remapRepopulatedChildSources(
if (clone?.componentId !== previous.id) continue
graph.updateNode(cloneId, { componentId: replacement.id })
if (cloneSources) {
const replacements = cloneSources.get(replacement.id)
if (replacements) {
if (!replacements.includes(cloneId)) replacements.push(cloneId)
} else cloneSources.set(replacement.id, [cloneId])
let replacements = cloneSources.get(replacement.id)
if (!replacements) {
replacements = []
cloneSources.set(replacement.id, replacements)
}
if (!replacements.includes(cloneId)) {
replacements.push(cloneId)
let known = cloneSourceIds(cloneSources).get(replacement.id)
if (!known) {
known = new Set()
cloneSourceIds(cloneSources).set(replacement.id, known)
}
known.add(cloneId)
}
}
}
}

View file

@ -0,0 +1,15 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
export function* overrideCandidates(
graph: SceneGraph,
activeNodeIds?: Set<string>
): Iterable<SceneNode> {
if (!activeNodeIds) {
yield* graph.getAllNodes()
return
}
for (const id of activeNodeIds) {
const node = graph.getNode(id)
if (node) yield node
}
}

View file

@ -27,6 +27,37 @@ describe('@open-pencil/fig instance interpretation', () => {
expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label')
})
test('limits lazy population to required global propagation scans', () => {
const graph = new SceneGraph()
const activePage = graph.getPages()[0]
const unrelatedPage = graph.addPage('Unrelated')
const component = graph.createNode('COMPONENT', unrelatedPage.id, {
width: 100,
height: 40
})
graph.createNode('TEXT', component.id, { text: 'Label' })
const instance = graph.createNode('INSTANCE', activePage.id, {
width: 100,
height: 40,
componentId: component.id
})
for (let index = 0; index < 5_000; index++) {
graph.createNode('RECTANGLE', unrelatedPage.id)
}
let globalScans = 0
const getAllNodes = graph.getAllNodes.bind(graph)
graph.getAllNodes = () => {
globalScans++
return getAllNodes()
}
populateAndApplyOverrides(graph, new Map(), new Map(), [], [activePage.id])
expect(graph.getNode(instance.id)?.childIds).toHaveLength(1)
expect(globalScans).toBe(2)
})
test('resolves text clone chains to their source values', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id

View file

@ -8,7 +8,6 @@ import { useEditor } from '#vue/editor/context'
import { provideLayerTree } from '#vue/primitives/LayerTree/context'
import {
buildLayerTreeModel,
indexLayerNodes,
layerSelectionForTarget,
patchLayerNode,
visibleLayerRows
@ -55,14 +54,29 @@ const { draggingId, instruction, instructionTargetId, setupItem } = useLayerDrag
expandNode
)
let rebuildPending = false
let rebuildToken = 0
function rebuildTree() {
rebuildPending = false
rebuildToken++
const model = buildLayerTreeModel(editor.graph, editor.state.currentPageId)
items.value = model.items
nodesById = indexLayerNodes(items.value)
nodesById = model.byId
expanded.value = expanded.value.filter((id) => nodesById.has(id))
treeVersion.value++
}
function scheduleTreeRebuild() {
if (rebuildPending) return
rebuildPending = true
const token = ++rebuildToken
queueMicrotask(() => {
if (!rebuildPending || token !== rebuildToken) return
rebuildTree()
})
}
rebuildTree()
const PATCHABLE_NODE_KEYS = new Set<keyof SceneNode>([
@ -127,10 +141,10 @@ function onSelectionChanged(ids: string[]) {
const unsubscribe = [
editor.onEditorEvent('graph:replaced', rebuildTree),
editor.onEditorEvent('page:changed', rebuildTree),
editor.onEditorEvent('node:created', rebuildTree),
editor.onEditorEvent('node:deleted', rebuildTree),
editor.onEditorEvent('node:reparented', rebuildTree),
editor.onEditorEvent('node:reordered', rebuildTree),
editor.onEditorEvent('node:created', scheduleTreeRebuild),
editor.onEditorEvent('node:deleted', scheduleTreeRebuild),
editor.onEditorEvent('node:reparented', scheduleTreeRebuild),
editor.onEditorEvent('node:reordered', scheduleTreeRebuild),
editor.onEditorEvent('node:updated', patchTreeNode),
editor.onEditorEvent('selection:changed', onSelectionChanged)
]