|
2 | 2 | import { _ } from '@sveltia/i18n'; |
3 | 3 | import { Icon } from '@sveltia/ui'; |
4 | 4 | import { removeVisibilityResolver, waitForVisibility } from '@sveltia/utils/element'; |
5 | | - import { sleep } from '@sveltia/utils/misc'; |
6 | 5 | import { onMount } from 'svelte'; |
7 | 6 |
|
8 | 7 | import { |
9 | 8 | getAssetBlobURL, |
10 | 9 | getAssetThumbnailURL, |
11 | 10 | revokeAssetBlobURLIfNeeded, |
| 11 | + revokeBlobURLIfNeeded, |
12 | 12 | } from '$lib/services/assets/info'; |
13 | 13 | import { THUMBNAIL_KINDS } from '$lib/services/assets/kinds'; |
14 | 14 | import { requestFlushSync } from '$lib/services/utils/render'; |
|
77 | 77 | ); |
78 | 78 |
|
79 | 79 | 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 | + }; |
80 | 111 |
|
81 | 112 | /** |
82 | 113 | * Update the {@link src} property. |
|
93 | 124 | await waitForVisibility(mediaElement); |
94 | 125 | } |
95 | 126 |
|
| 127 | + const previousSrc = src; |
| 128 | +
|
96 | 129 | try { |
97 | 130 | src = isThumbnail ? await getAssetThumbnailURL(asset) : await getAssetBlobURL(asset); |
98 | 131 | } catch { |
99 | 132 | hasError = true; |
100 | 133 | } |
101 | 134 |
|
| 135 | + if (isThumbnail) { |
| 136 | + ownURL(src); |
| 137 | + } |
| 138 | +
|
| 139 | + if (previousSrc !== src) { |
| 140 | + releaseOwnedURL(previousSrc); |
| 141 | + } |
| 142 | +
|
102 | 143 | if (blurBackground && !blurImageURL && src) { |
103 | 144 | blurImageURL = src; |
104 | 145 | } |
|
120 | 161 | return; |
121 | 162 | } |
122 | 163 |
|
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) { |
128 | 178 | // 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 }); |
137 | 184 | }); |
| 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 | + } |
138 | 192 | } |
139 | 193 |
|
140 | 194 | // Enable a dissolve transition |
|
143 | 197 | } |
144 | 198 |
|
145 | 199 | 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 | | - } |
156 | 200 | }; |
157 | 201 |
|
158 | 202 | $effect(() => { |
|
162 | 206 | if (blurBackground && asset && !blurImageURL) { |
163 | 207 | (async () => { |
164 | 208 | blurImageURL = await getAssetThumbnailURL(asset, { cacheOnly: true }); |
| 209 | + ownURL(blurImageURL); |
165 | 210 | })(); |
166 | 211 | } |
167 | 212 | }); |
|
206 | 251 | revokeAssetBlobURLIfNeeded(asset); |
207 | 252 | } |
208 | 253 |
|
| 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 | +
|
209 | 262 | if (mediaElement) { |
210 | 263 | removeVisibilityResolver(mediaElement); |
211 | 264 | } |
|
0 commit comments