* fix(canvas): bound image decoding for large Figma documents * test(canvas): update lifecycle fixture for image caches * test(canvas): align image regressions with contribution guides Use native wheel zoom in the browser regression instead of inline store access. Keep export state restoration in the existing raster suite and explicitly format the new image tests omitted by the root format script. * fix(harness): pin patched proxy address dependency Override the transitive proxy-addr dependency with 2.0.8 to fix IPv4-mapped IPv6 trust subnet checks and pass the critical dependency audit (GHSA-jqcg-44mw-7w3h). * build: drop the proxy-addr override master already resolves Master's lockfile has proxy-addr 2.0.8 since the advisory was fixed upstream, so the override this branch added is redundant. * perf(canvas): encode image previews as WebP PNG previews of photos ran to several megabytes each, so a page of them filled the 64 MiB preview cache and evicted itself: page 2 of the #924 reproduction held 48 MB of PNG previews against 7.3 MB as WebP. Browsers that cannot encode WebP hand back PNG. Co-authored-by: sableangle <sableangle@gmail.com> * perf(canvas): decode four image previews at once The browser decodes previews off the main thread, so one at a time left a page of photos filling in for seconds: page 2 of the #924 reproduction took 4.6 s to finish and now takes 2.0 s. Co-authored-by: sableangle <sableangle@gmail.com> * refactor(canvas): decide on image previews once per document size needsImagePreviews replaces useViewportImageRendering, which was not a Vue composable, names its two thresholds, and keeps its answer per document and image count instead of summing every image on each frame. Choosing a preview by zoom carries a lint exception: preview mode draws the scene uncached. Co-authored-by: sableangle <sableangle@gmail.com> * docs: describe the image memory fix in the changelog Co-authored-by: sableangle <sableangle@gmail.com> * fix(canvas): let go of a document's image previews when the document changes Switching to a document that needs no previews left the preview cache holding the previous graph and its encoded images, often hundreds of megabytes, until previews were used again. The renderer now releases them when its document changes, keeping the decoder for the next one. --------- Co-authored-by: sableangle <sableangle@gmail.com>
106 lines
3.9 KiB
TypeScript
106 lines
3.9 KiB
TypeScript
import { expect, mock, test } from 'bun:test'
|
|
|
|
import type { Font, Paint, Surface } from 'canvaskit-wasm'
|
|
|
|
import { createImageCache } from '#core/canvas/images/cache'
|
|
import { ImagePreviewCache } from '#core/canvas/images/previews'
|
|
import type { SkiaRenderer } from '#core/canvas/renderer'
|
|
import { EffectRasterCache } from '#core/canvas/renderer/effect-raster-cache'
|
|
import { destroyRenderer } from '#core/canvas/renderer/lifecycle'
|
|
import { createGlyphSilhouetteCache } from '#core/canvas/text/derived'
|
|
import { TextPreparationCache } from '#core/canvas/text/preparation-cache'
|
|
|
|
import { asDouble, asRenderer } from './helpers'
|
|
|
|
function deletable<T>() {
|
|
return { delete: mock() } as T & { delete: ReturnType<typeof mock> }
|
|
}
|
|
|
|
function createRenderer() {
|
|
const renderer: Partial<SkiaRenderer> = {
|
|
destroyed: false,
|
|
textPreparationCache: new TextPreparationCache(),
|
|
transientPreviews: new Map(),
|
|
imageCache: createImageCache(),
|
|
imagePreviews: new ImagePreviewCache(() => undefined),
|
|
onImagePreviewReady: null,
|
|
vectorPathCache: new Map(),
|
|
vectorStrokePathCache: new Map(),
|
|
vectorStrokeOutlineCache: new Map(),
|
|
fillGeometryCache: new Map(),
|
|
strokeGeometryCache: new Map(),
|
|
glyphSilhouetteCache: createGlyphSilhouetteCache(),
|
|
fillPaint: deletable<Paint>(),
|
|
strokePaint: deletable<Paint>(),
|
|
selectionPaint: deletable<Paint>(),
|
|
parentOutlinePaint: deletable<Paint>(),
|
|
snapPaint: deletable<Paint>(),
|
|
auxFill: deletable<Paint>(),
|
|
auxStroke: deletable<Paint>(),
|
|
opacityPaint: deletable<Paint>(),
|
|
effectLayerPaint: deletable<Paint>(),
|
|
textFont: deletable<Font>(),
|
|
labelFont: deletable<Font>(),
|
|
sizeFont: deletable<Font>(),
|
|
sectionTitleFont: deletable<Font>(),
|
|
componentLabelFont: deletable<Font>(),
|
|
fontMgr: null,
|
|
fontProvider: null,
|
|
fontsLoaded: true,
|
|
rulerBgPaint: deletable<Paint>(),
|
|
rulerTickPaint: deletable<Paint>(),
|
|
rulerTextPaint: deletable<Paint>(),
|
|
rulerHlPaint: deletable<Paint>(),
|
|
rulerBadgePaint: deletable<Paint>(),
|
|
rulerLabelPaint: deletable<Paint>(),
|
|
penPathPaint: deletable<Paint>(),
|
|
penLiveStrokePaint: deletable<Paint>(),
|
|
penHandlePaint: deletable<Paint>(),
|
|
penVertexFill: deletable<Paint>(),
|
|
penVertexStroke: deletable<Paint>(),
|
|
imageFilterCache: new Map(),
|
|
maskFilterCache: new Map(),
|
|
nodePictureCache: new Map(),
|
|
effectRasterCache: new EffectRasterCache(),
|
|
subtreePictureCache: new Map(),
|
|
scenePicture: null,
|
|
sceneBacking: null,
|
|
sceneBackingBuild: null,
|
|
tiledScene: asDouble<SkiaRenderer['tiledScene']>({ destroy: mock() }),
|
|
labelParagraphCache: asDouble<SkiaRenderer['labelParagraphCache']>({ clear: mock() }),
|
|
_flashPaint: null,
|
|
profiler: { destroy: mock() } as Partial<SkiaRenderer['profiler']> as SkiaRenderer['profiler'],
|
|
surface: deletable<Surface>()
|
|
}
|
|
return asRenderer(renderer)
|
|
}
|
|
|
|
test('destroyRenderer releases tiled resources before deleting the main surface', () => {
|
|
const renderer = createRenderer()
|
|
const teardown: string[] = []
|
|
renderer.textPreparationCache.clear = mock(() => {
|
|
teardown.push('text')
|
|
})
|
|
renderer.tiledScene.destroy = mock(() => teardown.push('tiled'))
|
|
renderer.surface.delete = mock(() => teardown.push('surface')) as typeof renderer.surface.delete
|
|
|
|
destroyRenderer(renderer)
|
|
|
|
expect(renderer.tiledScene.destroy).toHaveBeenCalledTimes(1)
|
|
expect(renderer.surface.delete).toHaveBeenCalledTimes(1)
|
|
expect(teardown).toEqual(['text', 'tiled', 'surface'])
|
|
})
|
|
|
|
test('destroyRenderer deletes all renderer-owned paints and label fonts', () => {
|
|
const renderer = createRenderer()
|
|
const parentOutlinePaint = renderer.parentOutlinePaint
|
|
const sectionTitleFont = renderer.sectionTitleFont
|
|
const componentLabelFont = renderer.componentLabelFont
|
|
|
|
destroyRenderer(renderer)
|
|
|
|
expect(parentOutlinePaint.delete).toHaveBeenCalled()
|
|
expect(sectionTitleFont?.delete).toHaveBeenCalled()
|
|
expect(componentLabelFont?.delete).toHaveBeenCalled()
|
|
})
|