clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

fix: swap rendered SVG previews atomically

Dami committed c06b214
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>
2 files changed, +135 -12
Mapps/editor/src/preview.js70+++++++++++++++++++++++-----
Mapps/editor/test/preview-routing.test.js77++++++++++++++++++++++++++++++
M apps/editor/src/preview.js +58 -12
@@ -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
M apps/editor/test/preview-routing.test.js +77 -0
@@ -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+})