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:
parent
a7a44e90b2
commit
fc8bccac7f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
packages/fig/src/instance-overrides/utils.ts
Normal file
15
packages/fig/src/instance-overrides/utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue