Decode replacement SVGs off-screen, commit image and substrate state together, retain the prior blob until the swap, and discard stale renders. Add regression coverage for delayed and superseded swaps.
- commit
- c06b214f68855b15ae24235264f2a5a624b4967f
- parent
- 320f12e5629df096a2b8de2c61882dd930b74123
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 70 | +++++++++++++++++++++++----- |
| M | apps/ | 77 | ++++++++++++++++++++++++++++++ |
@@ -54,6 +54,7 @@ export class PreviewController {
5454 this.x = 05555 this.y = 05656 this.objectUrl = null57+ this.imageSwapNumber = 05758 this.abortController = null5859 this.requestNumber = 05960 this.pendingRender = null@@ -103,6 +104,7 @@ export class PreviewController {
103104 render({ type, source, options = {}, meta = {}, presentation = {} }) {104105 if (!source.trim()) {105106 this.requestNumber++107+ this.imageSwapNumber++106108 this.pendingRender = null107109 this.abortController?.abort()108110 this.setStatus('Write something to render.', 'idle')@@ -118,6 +120,7 @@ export class PreviewController {
118120 const renderKey = JSON.stringify({ type, source, options, meta, presentation })119121 if (this.latestRenderKey === renderKey && this.latestSvgBlob) return Promise.resolve()120122 123+ this.imageSwapNumber++121124 this.pendingRender = { type, source, options, meta, presentation, renderKey, requestNumber: ++this.requestNumber }122125 this.abortController?.abort()123126 if (!this.renderLoop) this.renderLoop = this.drainRenderQueue()@@ -200,23 +203,66 @@ export class PreviewController {
200203 ? materializePresentation(canonicalSvg, presentation)201204 : materializeSvg(canonicalSvg, displayAppearance)202205 const displayed = this.normalizedSvgBlob(displayedSvg)206+ const nextObjectUrl = URL.createObjectURL(displayed.blob)207+ const swapNumber = ++this.imageSwapNumber208+ this.setStatus('Loading image…', 'loading')209+ void this.swapDecodedImage({210+ objectUrl: nextObjectUrl,211+ fallbackUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(displayedSvg)}`,212+ background: previewBackgroundForAppearance(displayAppearance, displayed.background),213+ theme: previewThemeForAppearance(displayAppearance),214+ }, swapNumber)215+ }216+217+ async decodeImage(url) {218+ const image = new Image()219+ image.decoding = 'async'220+ if (typeof image.decode === 'function') {221+ image.src = url222+ await image.decode()223+ return224+ }225+ await new Promise((resolve, reject) => {226+ image.addEventListener('load', resolve, { once: true })227+ image.addEventListener('error', () => reject(new Error('The rendered image was blocked or invalid.')), { once: true })228+ image.src = url229+ })230+ }231+232+ async swapDecodedImage(next, swapNumber) {233+ let activeUrl = next.objectUrl234+ try {235+ await this.decodeImage(activeUrl)236+ } catch {237+ activeUrl = next.fallbackUrl238+ try {239+ await this.decodeImage(activeUrl)240+ } catch (error) {241+ URL.revokeObjectURL(next.objectUrl)242+ if (swapNumber === this.imageSwapNumber) this.handleRenderError(error.message)243+ return244+ }245+ }246+247+ if (swapNumber !== this.imageSwapNumber) {248+ URL.revokeObjectURL(next.objectUrl)249+ return250+ }251+203252 this.previousImageSize = !this.fitOnNextLoad && this.image.naturalWidth && this.image.naturalHeight204253 ? { width: this.image.naturalWidth, height: this.image.naturalHeight }205254 : null206- this.imageFallbackUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(displayedSvg)}`207- this.stage.dataset.previewTheme = previewThemeForAppearance(displayAppearance)208- this.stage.style.setProperty(209- '--render-background',210- previewBackgroundForAppearance(displayAppearance, displayed.background),211- )212- const nextObjectUrl = URL.createObjectURL(displayed.blob)255+ this.stage.dataset.previewTheme = next.theme256+ this.stage.style.setProperty('--render-background', next.background)257+213258 const previousObjectUrl = this.objectUrl214- this.objectUrl = nextObjectUrl215- this.activeImageUrl = nextObjectUrl216- this.image.src = nextObjectUrl217- this.minimapImage.src = nextObjectUrl259+ this.objectUrl = activeUrl === next.objectUrl ? next.objectUrl : null260+ this.imageFallbackUrl = activeUrl === next.objectUrl ? next.fallbackUrl : null261+ this.activeImageUrl = activeUrl262+ this.image.src = activeUrl263+ this.minimapImage.src = activeUrl218264 this.image.hidden = false219- this.setStatus('Loading image…', 'loading')265+ if (activeUrl !== next.objectUrl) URL.revokeObjectURL(next.objectUrl)220266 if (previousObjectUrl) URL.revokeObjectURL(previousObjectUrl)221267 }222268 @@ -244,3 +244,80 @@ test('fits once when a different diagram identity loads', () => {
244244 assert.deepEqual(calls, ['fit'])245245 assert.equal(controller.fitOnNextLoad, false)246246 })247+248+test('keeps the current image until its decoded replacement can be committed', async () => {249+ let finishDecode250+ const revoked = []251+ const originalRevokeObjectUrl = URL.revokeObjectURL252+ URL.revokeObjectURL = url => revoked.push(url)253+ const image = { src: 'blob:current', hidden: false, naturalWidth: 120, naturalHeight: 80 }254+ const minimapImage = { src: 'blob:current' }255+ const backgrounds = []256+ const controller = {257+ imageSwapNumber: 2,258+ objectUrl: 'blob:current',259+ activeImageUrl: 'blob:current',260+ imageFallbackUrl: null,261+ image,262+ minimapImage,263+ fitOnNextLoad: false,264+ stage: {265+ dataset: { previewTheme: 'light' },266+ style: { setProperty(name, value) { backgrounds.push([name, value]) } },267+ },268+ decodeImage() { return new Promise(resolve => { finishDecode = resolve }) },269+ }270+271+ try {272+ const swap = PreviewController.prototype.swapDecodedImage.call(controller, {273+ objectUrl: 'blob:next',274+ fallbackUrl: 'data:next',275+ background: 'var(--preview-dark-bg)',276+ theme: 'dark',277+ }, 2)278+279+ await Promise.resolve()280+ assert.equal(image.src, 'blob:current')281+ assert.equal(minimapImage.src, 'blob:current')282+ assert.deepEqual(revoked, [])283+284+ finishDecode()285+ await swap286+287+ assert.equal(image.src, 'blob:next')288+ assert.equal(minimapImage.src, 'blob:next')289+ assert.equal(controller.stage.dataset.previewTheme, 'dark')290+ assert.deepEqual(backgrounds, [['--render-background', 'var(--preview-dark-bg)']])291+ assert.deepEqual(revoked, ['blob:current'])292+ } finally {293+ URL.revokeObjectURL = originalRevokeObjectUrl294+ }295+})296+297+test('discards a decoded image when a newer render has superseded it', async () => {298+ const revoked = []299+ const originalRevokeObjectUrl = URL.revokeObjectURL300+ URL.revokeObjectURL = url => revoked.push(url)301+ const controller = {302+ imageSwapNumber: 4,303+ objectUrl: 'blob:current',304+ image: { src: 'blob:current' },305+ minimapImage: { src: 'blob:current' },306+ async decodeImage() {},307+ }308+309+ try {310+ await PreviewController.prototype.swapDecodedImage.call(controller, {311+ objectUrl: 'blob:stale',312+ fallbackUrl: 'data:stale',313+ background: '#000000',314+ theme: 'dark',315+ }, 3)316+317+ assert.equal(controller.image.src, 'blob:current')318+ assert.equal(controller.minimapImage.src, 'blob:current')319+ assert.deepEqual(revoked, ['blob:stale'])320+ } finally {321+ URL.revokeObjectURL = originalRevokeObjectUrl322+ }323+})