Skip to content

Commit 9e99ea0

Browse files
committed
Fix asset preview blob URL cleanup
Fix #944
1 parent 4a3bdd0 commit 9e99ea0

3 files changed

Lines changed: 132 additions & 29 deletions

File tree

src/lib/components/assets/shared/asset-preview.svelte

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
import { _ } from '@sveltia/i18n';
33
import { Icon } from '@sveltia/ui';
44
import { removeVisibilityResolver, waitForVisibility } from '@sveltia/utils/element';
5-
import { sleep } from '@sveltia/utils/misc';
65
import { onMount } from 'svelte';
76
87
import {
98
getAssetBlobURL,
109
getAssetThumbnailURL,
1110
revokeAssetBlobURLIfNeeded,
11+
revokeBlobURLIfNeeded,
1212
} from '$lib/services/assets/info';
1313
import { THUMBNAIL_KINDS } from '$lib/services/assets/kinds';
1414
import { requestFlushSync } from '$lib/services/utils/render';
@@ -77,6 +77,37 @@
7777
);
7878
7979
let updatingSrc = false;
80+
/**
81+
* Object URLs created by this preview. Every `getAssetThumbnailURL()` call returns a URL of its
82+
* own, so these have to be released here — `revokeAssetBlobURLIfNeeded()` only knows about the
83+
* one URL shared on the asset itself. Kept outside the reactive graph so the cleanup below can
84+
* read it after the component is destroyed.
85+
* @type {string[]}
86+
*/
87+
const ownedURLs = [];
88+
89+
/**
90+
* Remember an object URL this preview created, so that it can be released later.
91+
* @param {string | undefined} url Object URL.
92+
*/
93+
const ownURL = (url) => {
94+
if (url && !ownedURLs.includes(url)) {
95+
ownedURLs.push(url);
96+
}
97+
};
98+
99+
/**
100+
* Release an object URL this preview created, once no element is displaying it any more.
101+
* @param {string | undefined} url Object URL.
102+
*/
103+
const releaseOwnedURL = (url) => {
104+
const index = url ? ownedURLs.indexOf(url) : -1;
105+
106+
if (index > -1) {
107+
ownedURLs.splice(index, 1);
108+
revokeBlobURLIfNeeded(url);
109+
}
110+
};
80111
81112
/**
82113
* Update the {@link src} property.
@@ -93,12 +124,22 @@
93124
await waitForVisibility(mediaElement);
94125
}
95126
127+
const previousSrc = src;
128+
96129
try {
97130
src = isThumbnail ? await getAssetThumbnailURL(asset) : await getAssetBlobURL(asset);
98131
} catch {
99132
hasError = true;
100133
}
101134
135+
if (isThumbnail) {
136+
ownURL(src);
137+
}
138+
139+
if (previousSrc !== src) {
140+
releaseOwnedURL(previousSrc);
141+
}
142+
102143
if (blurBackground && !blurImageURL && src) {
103144
blurImageURL = src;
104145
}
@@ -120,21 +161,34 @@
120161
return;
121162
}
122163
123-
if (
124-
isImage
125-
? !(/** @type {HTMLImageElement} */ (mediaElement).complete)
126-
: !(/** @type {HTMLMediaElement} */ (mediaElement).readyState)
127-
) {
164+
// The element’s own readiness only means anything once the DOM actually reflects `mediaSrc`.
165+
// An `<img>` whose `src` attribute hasn’t been written yet reports `complete === true`, which
166+
// would otherwise mark the preview loaded — and, back when that signal also revoked the blob
167+
// URL, kill the image just as the real `src` was applied. @see
168+
// https://github.com/sveltia/sveltia-cms/issues/944
169+
const isSrcApplied = mediaElement.getAttribute('src') === mediaSrc;
170+
171+
const isReady =
172+
isSrcApplied &&
173+
(isImage
174+
? /** @type {HTMLImageElement} */ (mediaElement).complete
175+
: !!(/** @type {HTMLMediaElement} */ (mediaElement).readyState));
176+
177+
if (!isReady) {
128178
// Not loaded yet; wait until it’s ready
129-
await new Promise((resolve) => {
130-
mediaElement?.addEventListener(
131-
isImage ? 'load' : 'loadedmetadata',
132-
() => {
133-
resolve(undefined);
134-
},
135-
{ once: true },
136-
);
179+
const failed = await new Promise((resolve) => {
180+
mediaElement?.addEventListener(isImage ? 'load' : 'loadedmetadata', () => resolve(false), {
181+
once: true,
182+
});
183+
mediaElement?.addEventListener('error', () => resolve(true), { once: true });
137184
});
185+
186+
if (failed) {
187+
// Show the fallback icon rather than an empty tile that never finishes its transition
188+
hasError = true;
189+
190+
return;
191+
}
138192
}
139193
140194
// Enable a dissolve transition
@@ -143,16 +197,6 @@
143197
}
144198
145199
loaded = true;
146-
147-
// Revoke the thumbnail blob URL
148-
if (asset && isThumbnail && src?.startsWith('blob:')) {
149-
// Wait a bit before revoking the thumbnail blob URL to ensure the image is rendered.
150-
// Otherwise, especially on Chrome, the image may fail to render without this delay. @see
151-
// https://github.com/sveltia/sveltia-cms/issues/793
152-
await sleep(500);
153-
154-
URL.revokeObjectURL(src);
155-
}
156200
};
157201
158202
$effect(() => {
@@ -162,6 +206,7 @@
162206
if (blurBackground && asset && !blurImageURL) {
163207
(async () => {
164208
blurImageURL = await getAssetThumbnailURL(asset, { cacheOnly: true });
209+
ownURL(blurImageURL);
165210
})();
166211
}
167212
});
@@ -206,6 +251,14 @@
206251
revokeAssetBlobURLIfNeeded(asset);
207252
}
208253
254+
// The revocation is batched into the next frame and skips any URL an element is still
255+
// displaying, which is what keeps an image that hasn’t finished decoding — or one that
256+
// outlives this component — from losing its source. @see
257+
// https://github.com/sveltia/sveltia-cms/issues/944
258+
ownedURLs.splice(0).forEach((url) => {
259+
revokeBlobURLIfNeeded(url);
260+
});
261+
209262
if (mediaElement) {
210263
removeVisibilityResolver(mediaElement);
211264
}

src/lib/services/assets/info.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -320,28 +320,39 @@ const flushRevocations = () => {
320320
};
321321

322322
/**
323-
* Revoke the blob URL for the given asset if it’s not being used in any elements.
323+
* Revoke the given blob URL if it’s not being used in any elements.
324324
*
325325
* The revocations are batched, because every asset preview asks for one as it unmounts: leaving an
326326
* asset grid would otherwise run a document-wide query and a scan of every asset once per preview,
327327
* which is O(assets²) in the frame the page navigates away.
328-
* @param {Asset} asset Asset.
328+
*
329+
* Deferring to the next frame is also what keeps a still-decoding image working: the flush skips
330+
* any URL an element is displaying, so a thumbnail is only released once nothing points at it.
331+
* @param {string | undefined} url Blob URL, or `undefined`/a non-blob URL to ignore.
329332
*/
330-
export const revokeAssetBlobURLIfNeeded = ({ blobURL }) => {
331-
if (!blobURL) {
333+
export const revokeBlobURLIfNeeded = (url) => {
334+
if (!url?.startsWith('blob:')) {
332335
return;
333336
}
334337

335338
const isFirst = !pendingRevocations.size;
336339

337340
// Queue before scheduling, so the flush can never observe an empty queue
338-
pendingRevocations.add(blobURL);
341+
pendingRevocations.add(url);
339342

340343
if (isFirst) {
341344
window.requestAnimationFrame(flushRevocations);
342345
}
343346
};
344347

348+
/**
349+
* Revoke the blob URL for the given asset if it’s not being used in any elements.
350+
* @param {Asset} asset Asset.
351+
*/
352+
export const revokeAssetBlobURLIfNeeded = ({ blobURL }) => {
353+
revokeBlobURLIfNeeded(blobURL);
354+
};
355+
345356
/**
346357
* Get the public URL for the given asset.
347358
* @param {Asset} asset Asset file, such as an image.

src/lib/services/assets/info.test.js

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)