perf: stop repeating glyph coverage and component sync during layout (#957)

* perf(text): keep glyph coverage when layout resizes a text box

Every layout write invalidated a text node's coverage record by id, and the record compared its width and height, so each measurement shaped the text twice. Coverage now ignores the box size unless the text is truncated, and an update that changes only fields coverage compares keeps the record. A demo load went from about 38,000 coverage checks with 228 hits to one shaping per text node.

* perf(editor): skip component sync for layout's own writes

Layout lays out instances itself, and the edit that made it run has already scheduled its sync. Its size and position writes scheduled about 56,000 more, so each yield of an async build re-synchronised components and re-laid out whole pages.
This commit is contained in:
Danila Poyarkov 2026-10-07 18:46:56 +00:00 committed by GitHub
parent 6a9b69f677
commit cb7018a2d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 101 additions and 15 deletions

View file

@ -224,6 +224,7 @@
- Open the `/demo` document like any `.fig` file, built ahead of time, instead of generating it in the browser, which froze the page for several seconds.
- 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.
- Lay out documents with many text layers and component instances without long freezes: text checks whether its fonts cover every glyph once rather than on every layout pass, and layout updating sizes no longer re-synchronises the components it touched. Building the demo document takes less than half as long.
### Security

View file

@ -511,8 +511,9 @@ export class SkiaRenderer {
RendererState.invalidateAllPictures(this)
}
invalidateNodePicture(nodeId: string): void {
RendererState.invalidateNodePicture(this, nodeId)
/** Drops `nodeId`'s cached drawing; `changedKeys`, when known, lets text keep its glyph coverage. */
invalidateNodePicture(nodeId: string, changedKeys?: readonly (keyof SceneNode)[]): void {
RendererState.invalidateNodePicture(this, nodeId, changedKeys)
}
flashNode(nodeId: string): void {

View file

@ -1,4 +1,7 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { changesGlyphCoverage } from '#core/canvas/text/paragraph-inputs'
export function invalidateScenePicture(r: SkiaRenderer): void {
r.scenePicture?.delete()
@ -32,8 +35,14 @@ export function invalidateAllPictures(r: SkiaRenderer): void {
clearSubtreePictureCache(r)
}
export function invalidateNodePicture(r: SkiaRenderer, nodeId: string): void {
r.textPreparationCache.deleteNode(nodeId)
export function invalidateNodePicture(
r: SkiaRenderer,
nodeId: string,
changedKeys?: readonly (keyof SceneNode)[]
): void {
r.textPreparationCache.deleteNode(nodeId, {
keepGlyphCoverage: changedKeys !== undefined && !changesGlyphCoverage(changedKeys)
})
r.effectRasterCache.delete(nodeId)
r.effectRasterCache.deleteDependencies(nodeId)
for (const [ownerId, dependencyIds] of r.nodePictureCacheDependencies) {

View file

@ -29,3 +29,24 @@ export const PARAGRAPH_INPUT_KEYS = [
] as const satisfies readonly (keyof SceneNode)[]
export type ParagraphNode = Pick<SceneNode, (typeof PARAGRAPH_INPUT_KEYS)[number]>
const BOX_SIZE_KEYS = new Set<keyof SceneNode>(['width', 'height'])
/**
* The paragraph inputs that decide which glyphs are shaped. The box size only matters when the
* text is truncated, since it decides which characters are shown; otherwise layout resizing the
* box leaves coverage as it was.
*/
export function glyphCoverageInputs(node: ParagraphNode): ParagraphNode[keyof ParagraphNode][] {
const truncated = node.textTruncation === 'ENDING'
return PARAGRAPH_INPUT_KEYS.filter((key) => truncated || !BOX_SIZE_KEYS.has(key)).map(
(key) => node[key]
)
}
/** Whether a change to `keys` can change glyph coverage beyond what `glyphCoverageInputs` sees. */
export function changesGlyphCoverage(keys: readonly (keyof SceneNode)[]): boolean {
return keys.some(
(key) => !BOX_SIZE_KEYS.has(key) && (PARAGRAPH_INPUT_KEYS as readonly string[]).includes(key)
)
}

View file

@ -10,7 +10,7 @@ import type { missingGlyphOccurrences } from '#core/text/resolver'
const MAX_PREPARED_PARAGRAPHS = 1024
const MAX_PREPARED_TEXT_UNITS = 262_144
import { PARAGRAPH_INPUT_KEYS } from './paragraph-inputs'
import { glyphCoverageInputs, PARAGRAPH_INPUT_KEYS } from './paragraph-inputs'
type PreparationInput = SceneNode[(typeof PARAGRAPH_INPUT_KEYS)[number]]
@ -106,7 +106,12 @@ export class TextPreparationCache {
}
const inputs = this.glyphCoverage.get(node)
if (!inputs) return false
if (PARAGRAPH_INPUT_KEYS.every((prop, index) => inputs[index] === node[prop])) return true
const current = glyphCoverageInputs(node)
if (
current.length === inputs.length &&
current.every((input, index) => input === inputs[index])
)
return true
this.glyphCoverage.delete(node)
return false
}
@ -114,16 +119,17 @@ export class TextPreparationCache {
/** Call only after observing complete coverage with this cache's current font scope. */
recordGlyphCoverage(node: SceneNode): void {
this.invalidatedCoverage.delete(node.id)
this.glyphCoverage.set(
node,
PARAGRAPH_INPUT_KEYS.map((prop) => node[prop])
)
this.glyphCoverage.set(node, glyphCoverageInputs(node))
}
deleteNode(id: string): void {
/**
* Drops `id`'s paragraphs and, unless `keepGlyphCoverage`, its coverage. Keep coverage only for
* changes `glyphCoverageInputs` compares, such as layout resizing the box.
*/
deleteNode(id: string, { keepGlyphCoverage = false } = {}): void {
// Invalidation arrives by ID; don't add strong node ownership just to find
// weak observations. Bound pending IDs and conservatively reset on overflow.
this.invalidatedCoverage.add(id)
if (!keepGlyphCoverage) this.invalidatedCoverage.add(id)
if (this.invalidatedCoverage.size > this.maxEntries) {
this.glyphCoverage = new WeakMap()
this.invalidatedCoverage.clear()

View file

@ -100,7 +100,10 @@ export function createComponentSyncScheduler(
function scheduleComponentSync(nodeId: string) {
// Import/materialization has already resolved component overrides. These updates
// are not authored component edits and must not reset instances to their defaults.
if (isFlushingComponentSync || getGraph().isApplyingImportedState) return
// Layout's own writes are results, not edits: it lays out the instances too, and the edit
// that made it run has already scheduled its sync.
const graph = getGraph()
if (isFlushingComponentSync || graph.isApplyingImportedState || graph.isApplyingLayout) return
if (!pendingComponentSync) {
pendingComponentSync = new Set()
queueMicrotask(flushComponentSync)

View file

@ -70,7 +70,8 @@ function invalidateRenderersForChange(
const invalidation = rendererInvalidationForChanges(changes, { preview: !invalidateNodePicture })
for (const renderer of renderers) {
if (invalidation.geometryCache) renderer.invalidateVectorPath(id)
if (invalidation.nodePicture) renderer.invalidateNodePicture(id)
if (invalidation.nodePicture)
renderer.invalidateNodePicture(id, Object.keys(changes) as (keyof SceneNode)[])
if (Object.keys(changes).some((key) => TILED_CHUNK_TOPOLOGY_KEYS.has(key as keyof SceneNode))) {
renderer.tiledScene.invalidateStructure()
} else {

View file

@ -89,6 +89,27 @@ describe('component sync layout scope', () => {
expect(scopes).toEqual([])
})
test('layout writing its own results schedules no sync', async () => {
const { graph, label } = createGraph()
const scopes: (string | undefined)[] = []
const { scheduleComponentSync } = createComponentSyncScheduler(
() => graph,
() => undefined,
(innerGraph, scopeId) => {
scopes.push(scopeId)
computeAllLayouts(innerGraph, scopeId)
}
)
graph.withLayoutMutations(() => {
graph.updateNode(label.id, { width: 120 })
scheduleComponentSync(label.id)
})
await Promise.resolve()
expect(scopes).toEqual([])
})
test('a cross-page instance still receives the component layout', async () => {
const { graph, instance, component } = createGraph()
const { scheduleComponentSync } = createComponentSyncScheduler(

View file

@ -71,8 +71,19 @@ test('node picture invalidation removes pictures that depend on a changed child'
invalidateNodePicture(renderer, 'child')
expect(textPreparationCache.deleteNode).toHaveBeenCalledWith('child')
expect(textPreparationCache.deleteNode).toHaveBeenCalledWith('child', {
keepGlyphCoverage: false
})
expect(parentPicture.delete).toHaveBeenCalledTimes(1)
expect(childPicture.delete).toHaveBeenCalledTimes(1)
expect(renderer.nodePictureCache.size).toBe(0)
invalidateNodePicture(renderer, 'child', ['width', 'height', 'x'])
expect(textPreparationCache.deleteNode).toHaveBeenLastCalledWith('child', {
keepGlyphCoverage: true
})
invalidateNodePicture(renderer, 'child', ['width', 'text'])
expect(textPreparationCache.deleteNode).toHaveBeenLastCalledWith('child', {
keepGlyphCoverage: false
})
})

View file

@ -135,7 +135,19 @@ describe('text preparation cache', () => {
expect(f.cache.hasGlyphCoverage(f.node, 1, otherProvider)).toBe(false)
f.graph.updateNodePreview(f.node.id, { x: 50, rotation: 20 })
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
// Layout resizing the box shapes the same glyphs, unless truncation hides some of them.
f.graph.updateNodePreview(f.node.id, { width: 80 })
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
f.cache.deleteNode(f.node.id, { keepGlyphCoverage: true })
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(true)
f.graph.updateNodePreview(f.node.id, { textTruncation: 'ENDING' })
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
f.cache.recordGlyphCoverage(f.node)
f.graph.updateNodePreview(f.node.id, { width: 60 })
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
f.graph.updateNodePreview(f.node.id, { textTruncation: 'DISABLED' })
f.cache.recordGlyphCoverage(f.node)
f.cache.deleteNode(f.node.id)
expect(f.cache.hasGlyphCoverage(f.node, 1, f.provider)).toBe(false)
f.use('coverage', 1)
f.cache.recordGlyphCoverage(f.node)