perf(app): load images immediately instead of queueing four at a time (#1855)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:32:02 +02:00
1 parent 02406925d7
commit b49cf349a8
3 files changed
+155 -398

No files matched your search

@@ -1,26 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {useEffect, useState} from 'react';
export function useCachedImageLoaded(src: string | null | undefined): boolean {
const resolved = src ?? null;
const [readySrc, setReadySrc] = useState<string | null>(() =>
resolved !== null && ImageCacheUtils.hasImage(resolved) ? resolved : null,
);
useEffect(() => {
if (resolved === null) {
return;
}
let active = true;
const markReady = (): void => {
if (active) setReadySrc(resolved);
};
const cleanup = ImageCacheUtils.loadImage(resolved, markReady, markReady);
return () => {
active = false;
cleanup();
};
}, [resolved]);
return resolved !== null && readySrc === resolved;
}
@@ -1,100 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {afterEach, describe, expect, it, vi} from 'vitest';
const IMAGE_RETRY_CEILING_MS = 20_000;
afterEach(() => {
ImageCacheUtils._clearForTests();
});
describe('loadImage rejection path', () => {
it('reports an unusable source through onError so callers can fall back', () => {
const onLoad = vi.fn();
const onError = vi.fn();
ImageCacheUtils.loadImage(null, onLoad, onError);
expect(onLoad).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
});
it('reports an oversized source through onError rather than queueing it', () => {
const onLoad = vi.fn();
const onError = vi.fn();
ImageCacheUtils.loadImage(`https://cdn.test/${'a'.repeat(32 * 1024)}.png`, onLoad, onError);
expect(onLoad).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
});
it('returns a no-op disposer when it rejects, so cleanup stays safe', () => {
const dispose = ImageCacheUtils.loadImage(null, vi.fn(), vi.fn());
expect(() => dispose()).not.toThrow();
});
});
describe('image load retry', () => {
class StubImage {
static instances: Array<StubImage> = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
decoding = 'auto';
naturalWidth = 0;
naturalHeight = 0;
#src = '';
constructor() {
StubImage.instances.push(this);
}
get src(): string {
return this.#src;
}
set src(value: string) {
this.#src = value;
}
fail(): void {
this.onerror?.();
}
}
const withStubImage = (run: () => void): void => {
const original = globalThis.Image;
StubImage.instances = [];
globalThis.Image = StubImage as unknown as typeof Image;
try {
run();
} finally {
globalThis.Image = original;
}
};
it('retries a failed load instead of reporting failure immediately', () => {
vi.useFakeTimers();
withStubImage(() => {
const onError = vi.fn();
ImageCacheUtils.loadImage('https://cdn.test/a.png', vi.fn(), onError);
const first = StubImage.instances.at(-1);
expect(first).toBeDefined();
first?.fail();
expect(onError).not.toHaveBeenCalled();
vi.advanceTimersByTime(600);
expect(StubImage.instances.length).toBeGreaterThan(1);
});
vi.useRealTimers();
});
it('gives up after a bounded number of attempts and reports the failure once', () => {
vi.useFakeTimers();
withStubImage(() => {
const onError = vi.fn();
ImageCacheUtils.loadImage('https://cdn.test/b.png', vi.fn(), onError);
for (let attempt = 0; attempt < 12; attempt++) {
StubImage.instances.at(-1)?.fail();
vi.advanceTimersByTime(IMAGE_RETRY_CEILING_MS);
}
expect(StubImage.instances.length).toBeGreaterThan(1);
expect(StubImage.instances.length).toBeLessThanOrEqual(6);
expect(onError).toHaveBeenCalledTimes(1);
});
vi.useRealTimers();
});
});
@@ -2,93 +2,56 @@
import {LRUCache} from 'lru-cache'; import {LRUCache} from 'lru-cache';
interface ImageCacheEntry {
src: string;
naturalWidth: number;
naturalHeight: number;
pin: HTMLImageElement;
}
export interface CachedImageSize { export interface CachedImageSize {
width: number; width: number;
height: number; height: number;
} }
interface PendingImageSubscriber { interface ImageSubscriber {
onLoad: () => void; onLoad: () => void;
onError: (() => void) | undefined; onError: (() => void) | undefined;
} }
interface PendingImageLoad { interface ImageCacheEntry {
src: string;
loaded: boolean;
width: number;
height: number;
image: HTMLImageElement | null; image: HTMLImageElement | null;
subscribers: Set<PendingImageSubscriber>; subscribers: Set<ImageSubscriber>;
active: boolean;
timeoutId: number;
failedAttempts: number; failedAttempts: number;
retryDelayMs: number;
loadTimeoutId: number;
retryTimeoutId: number; retryTimeoutId: number;
connectivityListener: (() => void) | null; connectivityListener: (() => void) | null;
} }
const MAX_CACHE_ENTRIES = 500; const MAX_CACHE_ENTRIES = 1000;
const MAX_CACHE_BYTES = 64 * 1024 * 1024;
const MAX_CACHE_ENTRY_BYTES = 16 * 1024 * 1024;
const FALLBACK_IMAGE_BYTES = 256 * 1024;
const MAX_PENDING_IMAGE_LOADS = 64;
const MAX_ACTIVE_IMAGE_LOADS = 4;
const MAX_PENDING_IMAGE_CALLBACKS = 1024;
const MAX_PENDING_IMAGE_CALLBACKS_PER_LOAD = 256;
const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024; const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
const MAX_PENDING_IMAGE_CALLBACKS_PER_LOAD = 256;
const IMAGE_LOAD_TIMEOUT_MS = 30_000; const IMAGE_LOAD_TIMEOUT_MS = 30_000;
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
const IMAGE_RETRY_ATTEMPT_LIMIT = 5; const IMAGE_RETRY_ATTEMPT_LIMIT = 5;
const IMAGE_RETRY_INITIAL_DELAY_MS = 500; const IMAGE_RETRY_INITIAL_DELAY_MS = 500;
const IMAGE_RETRY_MAX_DELAY_MS = 15_000; const IMAGE_RETRY_MAX_DELAY_MS = IMAGE_RETRY_INITIAL_DELAY_MS * 10;
const getDimensionByteSize = (width: number, height: number): number | null => {
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
return null;
}
const byteSize = width * height * 4;
if (!Number.isSafeInteger(byteSize) || byteSize <= 0) return null;
return byteSize;
};
const getImageByteSize = (image: HTMLImageElement): number | null =>
getDimensionByteSize(image.naturalWidth, image.naturalHeight);
const estimateImageBytes = (entry: ImageCacheEntry): number => {
const byteSize = getDimensionByteSize(entry.naturalWidth, entry.naturalHeight);
if (byteSize == null) return MAX_CACHE_ENTRY_BYTES + FALLBACK_IMAGE_BYTES;
return byteSize;
};
const createImagePin = (src: string, image: HTMLImageElement): HTMLImageElement => {
if (image.parentNode == null) return image;
const pin = new Image();
pin.decoding = 'async';
pin.src = src;
return pin;
};
const imageCache = new LRUCache<string, ImageCacheEntry>({ const imageCache = new LRUCache<string, ImageCacheEntry>({
max: MAX_CACHE_ENTRIES, max: MAX_CACHE_ENTRIES,
maxSize: MAX_CACHE_BYTES, disposeAfter: (entry: ImageCacheEntry) => {
maxEntrySize: MAX_CACHE_ENTRY_BYTES, abandonEntry(entry);
sizeCalculation: estimateImageBytes, },
}); });
const pendingImageLoads = new Map<string, PendingImageLoad>();
let activeImageLoadCount = 0;
let pendingImageCallbackCount = 0;
const isLoadedImage = (image?: HTMLImageElement): image is HTMLImageElement => {
if (!image) return false;
return image.complete && image.naturalWidth > 0;
};
const isCacheableImage = (image: HTMLImageElement): boolean => {
const byteSize = getImageByteSize(image);
return byteSize != null && byteSize <= MAX_CACHE_ENTRY_BYTES;
};
const imageSourceEncoder = new TextEncoder(); const imageSourceEncoder = new TextEncoder();
const acceptsImageSource = (src: string | null | undefined): src is string =>
typeof src === 'string' &&
src.length > 0 &&
src.length <= MAX_IMAGE_SOURCE_LENGTH &&
imageSourceEncoder.encode(src).byteLength <= MAX_IMAGE_SOURCE_LENGTH;
const isLoadedImage = (image: HTMLImageElement | null | undefined): image is HTMLImageElement =>
image?.complete === true && image.naturalWidth > 0;
const imageHasSource = (image: HTMLImageElement, src: string): boolean => { const imageHasSource = (image: HTMLImageElement, src: string): boolean => {
if (image.currentSrc.length > 0) { if (image.currentSrc.length > 0) {
try { try {
@@ -97,147 +60,38 @@ const imageHasSource = (image: HTMLImageElement, src: string): boolean => {
return image.currentSrc === src; return image.currentSrc === src;
} }
} }
const attributeSource = image.getAttribute('src'); if (image.getAttribute('src') === src) return true;
if (attributeSource === src) return true;
return image.src === src; return image.src === src;
}; };
const acceptsImageSource = (src: string | null): src is string => {
return (
typeof src === 'string' &&
src.length > 0 &&
src.length <= MAX_IMAGE_SOURCE_LENGTH &&
imageSourceEncoder.encode(src).byteLength <= MAX_IMAGE_SOURCE_LENGTH
);
};
const isCached = (src: string | null): boolean => {
if (!acceptsImageSource(src)) return false;
const entry = imageCache.get(src);
if (!entry) return false;
if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) return true;
imageCache.delete(src);
return false;
};
export function hasImage(src: string | null): boolean { const ownsCacheKey = (entry: ImageCacheEntry): boolean => imageCache.peek(entry.src) === entry;
return isCached(src);
}
export function getImageSize(src: string | null): CachedImageSize | undefined { function clearRetryState(entry: ImageCacheEntry): void {
if (!acceptsImageSource(src)) return undefined; if (entry.retryTimeoutId !== 0) {
const entry = imageCache.get(src); window.clearTimeout(entry.retryTimeoutId);
if (!entry) return undefined; entry.retryTimeoutId = 0;
if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) {
return {width: entry.naturalWidth, height: entry.naturalHeight};
} }
imageCache.delete(src); if (entry.connectivityListener != null) {
return undefined; window.removeEventListener('online', entry.connectivityListener);
} entry.connectivityListener = null;
export function rememberImage(src: string | null, image: HTMLImageElement): void {
const currentPendingLoad = acceptsImageSource(src) ? pendingImageLoads.get(src) : undefined;
if (!acceptsImageSource(src) || !imageHasSource(image, src) || !isLoadedImage(image)) {
if (typeof src === 'string' && currentPendingLoad != null && currentPendingLoad.image === image) {
settlePendingImageLoad(src, currentPendingLoad, false, null);
}
return;
}
if (isCacheableImage(image)) {
imageCache.set(src, {
src,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
pin: createImagePin(src, image),
});
}
if (!currentPendingLoad) return;
settlePendingImageLoad(src, currentPendingLoad, true, image);
}
export function forgetImage(src: string | null): void {
if (!acceptsImageSource(src)) return;
imageCache.delete(src);
const currentPendingLoad = pendingImageLoads.get(src);
if (!currentPendingLoad) return;
settlePendingImageLoad(src, currentPendingLoad, false, null);
}
function retainPendingImageCallback(): void {
pendingImageCallbackCount += 1;
}
function releasePendingImageCallbacks(count: number): void {
pendingImageCallbackCount -= count;
if (pendingImageCallbackCount < 0) throw new Error('Pending image callback count became negative');
}
function releaseActiveImageLoad(): void {
activeImageLoadCount -= 1;
if (activeImageLoadCount < 0) throw new Error('Active image load count became negative');
}
function clearRetryState(pendingLoad: PendingImageLoad): void {
window.clearTimeout(pendingLoad.retryTimeoutId);
pendingLoad.retryTimeoutId = 0;
if (pendingLoad.connectivityListener != null) {
window.removeEventListener('online', pendingLoad.connectivityListener);
pendingLoad.connectivityListener = null;
} }
} }
function retryDelayForAttempt(failedAttempts: number): number { function detachImageLoad(entry: ImageCacheEntry): void {
return Math.min(IMAGE_RETRY_INITIAL_DELAY_MS * 2 ** (failedAttempts - 1), IMAGE_RETRY_MAX_DELAY_MS); if (entry.loadTimeoutId !== 0) {
window.clearTimeout(entry.loadTimeoutId);
entry.loadTimeoutId = 0;
}
if (entry.image != null) {
entry.image.onload = null;
entry.image.onerror = null;
entry.image = null;
}
} }
function scheduleImageLoadRetry(src: string, pendingLoad: PendingImageLoad): boolean { function notifySubscribers(entry: ImageCacheEntry, loaded: boolean): void {
if (pendingLoad.failedAttempts >= IMAGE_RETRY_ATTEMPT_LIMIT) return false; const subscribers = [...entry.subscribers];
pendingLoad.failedAttempts += 1; entry.subscribers.clear();
window.clearTimeout(pendingLoad.timeoutId);
pendingLoad.timeoutId = 0;
if (pendingLoad.image != null) {
pendingLoad.image.onload = null;
pendingLoad.image.onerror = null;
pendingLoad.image.src = 'data:,';
pendingLoad.image = null;
}
if (pendingLoad.active) {
pendingLoad.active = false;
releaseActiveImageLoad();
}
const resume = (): void => {
clearRetryState(pendingLoad);
if (pendingImageLoads.get(src) !== pendingLoad) return;
pendingLoad.timeoutId = window.setTimeout(() => {
settlePendingImageLoad(src, pendingLoad, false, null);
}, IMAGE_LOAD_ACTIVATION_TIMEOUT_MS);
pumpPendingImageLoads();
};
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
pendingLoad.connectivityListener = resume;
window.addEventListener('online', resume, {once: true});
} else {
pendingLoad.retryTimeoutId = window.setTimeout(resume, retryDelayForAttempt(pendingLoad.failedAttempts));
}
pumpPendingImageLoads();
return true;
}
function detachPendingImageLoad(src: string, pendingLoad: PendingImageLoad): boolean {
if (pendingImageLoads.get(src) !== pendingLoad) return false;
pendingImageLoads.delete(src);
window.clearTimeout(pendingLoad.timeoutId);
clearRetryState(pendingLoad);
if (pendingLoad.image != null) {
pendingLoad.image.onload = null;
pendingLoad.image.onerror = null;
}
if (pendingLoad.active) releaseActiveImageLoad();
return true;
}
function notifyPendingImageSubscribers(pendingLoad: PendingImageLoad, loaded: boolean): void {
const subscribers = [...pendingLoad.subscribers];
pendingLoad.subscribers.clear();
releasePendingImageCallbacks(subscribers.length);
const failures: Array<unknown> = []; const failures: Array<unknown> = [];
for (const subscriber of subscribers) { for (const subscriber of subscribers) {
try { try {
@@ -250,78 +104,95 @@ function notifyPendingImageSubscribers(pendingLoad: PendingImageLoad, loaded: bo
if (failures.length > 0) throw new AggregateError(failures, 'Image load callbacks failed'); if (failures.length > 0) throw new AggregateError(failures, 'Image load callbacks failed');
} }
function settlePendingImageLoad( function abandonEntry(entry: ImageCacheEntry): void {
src: string, clearRetryState(entry);
pendingLoad: PendingImageLoad, detachImageLoad(entry);
loaded: boolean, notifySubscribers(entry, false);
loadedImage: HTMLImageElement | null,
): void {
if (!detachPendingImageLoad(src, pendingLoad)) return;
if (pendingLoad.image != null && pendingLoad.image !== loadedImage) pendingLoad.image.src = 'data:,';
try {
notifyPendingImageSubscribers(pendingLoad, loaded);
} finally {
pumpPendingImageLoads();
}
} }
function discardPendingImageLoad(src: string, pendingLoad: PendingImageLoad): void { function failEntry(entry: ImageCacheEntry): void {
if (!detachPendingImageLoad(src, pendingLoad)) return; if (ownsCacheKey(entry)) imageCache.delete(entry.src);
if (pendingLoad.image != null) pendingLoad.image.src = 'data:,'; abandonEntry(entry);
const subscriberCount = pendingLoad.subscribers.size;
pendingLoad.subscribers.clear();
releasePendingImageCallbacks(subscriberCount);
pumpPendingImageLoads();
} }
function activatePendingImageLoad(src: string, pendingLoad: PendingImageLoad): void { function settleLoaded(entry: ImageCacheEntry, image: HTMLImageElement): void {
if (pendingLoad.active || pendingImageLoads.get(src) !== pendingLoad) return; clearRetryState(entry);
pendingLoad.active = true; detachImageLoad(entry);
activeImageLoadCount += 1; entry.loaded = true;
if (activeImageLoadCount > MAX_ACTIVE_IMAGE_LOADS) throw new Error('Active image load count exceeds its limit'); entry.width = image.naturalWidth;
window.clearTimeout(pendingLoad.timeoutId); entry.height = image.naturalHeight;
pendingLoad.timeoutId = window.setTimeout(() => { entry.failedAttempts = 0;
settlePendingImageLoad(src, pendingLoad, false, null); entry.retryDelayMs = IMAGE_RETRY_INITIAL_DELAY_MS;
}, IMAGE_LOAD_TIMEOUT_MS); notifySubscribers(entry, true);
}
function retryDelayForAttempt(entry: ImageCacheEntry): number {
const growth = 2 * entry.retryDelayMs * Math.random();
entry.retryDelayMs = Math.min(entry.retryDelayMs + growth, IMAGE_RETRY_MAX_DELAY_MS);
return entry.retryDelayMs;
}
function startImageLoad(entry: ImageCacheEntry): void {
const image = new Image(); const image = new Image();
image.decoding = 'async'; image.decoding = 'async';
pendingLoad.image = image; entry.image = image;
entry.loadTimeoutId = window.setTimeout(() => {
entry.loadTimeoutId = 0;
failEntry(entry);
}, IMAGE_LOAD_TIMEOUT_MS);
image.onload = () => { image.onload = () => {
if (pendingImageLoads.get(src) !== pendingLoad) return; detachImageLoad(entry);
rememberImage(src, image); if (!isLoadedImage(image)) {
scheduleRetryOrFail(entry);
return;
}
settleLoaded(entry, image);
}; };
image.onerror = () => { image.onerror = () => {
if (pendingImageLoads.get(src) !== pendingLoad) return; detachImageLoad(entry);
imageCache.delete(src); scheduleRetryOrFail(entry);
if (scheduleImageLoadRetry(src, pendingLoad)) return;
settlePendingImageLoad(src, pendingLoad, false, null);
}; };
image.src = src; image.src = entry.src;
} }
function pumpPendingImageLoads(): void { function scheduleRetryOrFail(entry: ImageCacheEntry): void {
if (activeImageLoadCount >= MAX_ACTIVE_IMAGE_LOADS) return; if (entry.failedAttempts >= IMAGE_RETRY_ATTEMPT_LIMIT) {
for (const [src, pendingLoad] of pendingImageLoads) { failEntry(entry);
if (activeImageLoadCount >= MAX_ACTIVE_IMAGE_LOADS) return; return;
if (!pendingLoad.active) activatePendingImageLoad(src, pendingLoad);
} }
entry.failedAttempts += 1;
const resume = (): void => {
clearRetryState(entry);
if (!ownsCacheKey(entry)) {
abandonEntry(entry);
return;
}
startImageLoad(entry);
};
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
entry.connectivityListener = resume;
window.addEventListener('online', resume, {once: true});
return;
}
entry.retryTimeoutId = window.setTimeout(resume, retryDelayForAttempt(entry));
} }
function createPendingImageLoad(src: string): PendingImageLoad { function createEntry(src: string): ImageCacheEntry {
const pendingLoad: PendingImageLoad = { const entry: ImageCacheEntry = {
src,
loaded: false,
width: 0,
height: 0,
image: null, image: null,
subscribers: new Set(), subscribers: new Set(),
active: false,
timeoutId: 0,
failedAttempts: 0, failedAttempts: 0,
retryDelayMs: IMAGE_RETRY_INITIAL_DELAY_MS,
loadTimeoutId: 0,
retryTimeoutId: 0, retryTimeoutId: 0,
connectivityListener: null, connectivityListener: null,
}; };
pendingLoad.timeoutId = window.setTimeout(() => { imageCache.set(src, entry);
settlePendingImageLoad(src, pendingLoad, false, null); return entry;
}, IMAGE_LOAD_ACTIVATION_TIMEOUT_MS);
pendingImageLoads.set(src, pendingLoad);
return pendingLoad;
} }
function rejectImageLoad(onError: (() => void) | undefined): () => void { function rejectImageLoad(onError: (() => void) | undefined): () => void {
@@ -329,47 +200,59 @@ function rejectImageLoad(onError: (() => void) | undefined): () => void {
return () => {}; return () => {};
} }
export function loadImage(src: string | null, onLoad: () => void, onError?: () => void): () => void { export function hasImage(src: string | null | undefined): boolean {
if (!acceptsImageSource(src)) return false;
return imageCache.get(src)?.loaded === true;
}
export function getImageSize(src: string | null | undefined): CachedImageSize | undefined {
if (!acceptsImageSource(src)) return undefined;
const entry = imageCache.get(src);
if (entry == null || !entry.loaded || entry.width <= 0 || entry.height <= 0) return undefined;
return {width: entry.width, height: entry.height};
}
export function rememberImage(src: string | null | undefined, image: HTMLImageElement): void {
if (!acceptsImageSource(src) || !imageHasSource(image, src) || !isLoadedImage(image)) return;
const entry = imageCache.get(src) ?? createEntry(src);
if (entry.loaded) return;
settleLoaded(entry, image);
}
export function forgetImage(src: string | null | undefined): void {
if (!acceptsImageSource(src)) return;
const entry = imageCache.get(src);
if (entry == null) return;
failEntry(entry);
}
export function loadImage(src: string | null | undefined, onLoad: () => void, onError?: () => void): () => void {
if (!acceptsImageSource(src)) return rejectImageLoad(onError); if (!acceptsImageSource(src)) return rejectImageLoad(onError);
if (isCached(src)) { const cached = imageCache.get(src);
if (cached?.loaded === true) {
onLoad(); onLoad();
return () => {}; return () => {};
} }
let pendingLoad = pendingImageLoads.get(src); if (cached != null && cached.subscribers.size >= MAX_PENDING_IMAGE_CALLBACKS_PER_LOAD) {
if (!pendingLoad) {
if (pendingImageLoads.size >= MAX_PENDING_IMAGE_LOADS) return rejectImageLoad(onError);
pendingLoad = createPendingImageLoad(src);
}
if (
pendingLoad.subscribers.size >= MAX_PENDING_IMAGE_CALLBACKS_PER_LOAD ||
pendingImageCallbackCount >= MAX_PENDING_IMAGE_CALLBACKS
) {
if (pendingLoad.subscribers.size === 0) discardPendingImageLoad(src, pendingLoad);
return rejectImageLoad(onError); return rejectImageLoad(onError);
} }
const subscriber: PendingImageSubscriber = {onLoad, onError}; const entry = cached ?? createEntry(src);
pendingLoad.subscribers.add(subscriber); const subscriber: ImageSubscriber = {onLoad, onError};
retainPendingImageCallback(); entry.subscribers.add(subscriber);
pumpPendingImageLoads(); if (cached == null) startImageLoad(entry);
return () => { return () => {
if (!pendingLoad.subscribers.delete(subscriber)) return; entry.subscribers.delete(subscriber);
releasePendingImageCallbacks(1);
if (pendingImageLoads.get(src) === pendingLoad && pendingLoad.subscribers.size === 0) {
discardPendingImageLoad(src, pendingLoad);
}
}; };
} }
export function pinImage(src: string | null): () => void { export function pinImage(src: string | null | undefined): () => void {
return loadImage(src, () => {}); return loadImage(src, () => {});
} }
export function warmImage(src: string | null | undefined): void {
loadImage(src, () => {});
}
export function _clearForTests(): void { export function _clearForTests(): void {
for (const [src, pendingLoad] of pendingImageLoads) {
discardPendingImageLoad(src, pendingLoad);
}
imageCache.clear(); imageCache.clear();
if (pendingImageLoads.size !== 0) throw new Error('Image cache retained pending loads after clear');
if (activeImageLoadCount !== 0) throw new Error('Image cache retained active loads after clear');
if (pendingImageCallbackCount !== 0) throw new Error('Image cache retained callbacks after clear');
} }