perf(app): preload picker images through the shared image cache (#1923)

This commit is contained in:
Hampus
2026-08-24 12:46:19 +02:00
committed by GitHub
parent 589a01a2da
commit eb4f41e80c
@@ -1,52 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {hasImage, loadImage} from '@app/features/messaging/utils/ImageCacheUtils';
import {useEffect} from 'react';
type ImagePreloadStatus = 'loading' | 'loaded' | 'error';
interface ImagePreloadEntry {
status: ImagePreloadStatus;
promise: Promise<void> | null;
lastUsed: number;
failedAt: number | null;
}
const MAX_IMAGE_PRELOAD_CACHE_ENTRIES = 512;
const IDLE_PRELOAD_BATCH_SIZE = 12;
const IMAGE_PRELOAD_ERROR_RETRY_DELAY_MS = 5000;
const MAX_TRACKED_FAILURES = 512;
const imagePreloadCache = new Map<string, ImagePreloadEntry>();
function shouldRetryFailedEntry(entry: ImagePreloadEntry): boolean {
return (
entry.status === 'error' &&
entry.failedAt !== null &&
Date.now() - entry.failedAt >= IMAGE_PRELOAD_ERROR_RETRY_DELAY_MS
);
}
function touchEntry(url: string, entry: ImagePreloadEntry): ImagePreloadEntry {
entry.lastUsed = Date.now();
imagePreloadCache.delete(url);
imagePreloadCache.set(url, entry);
return entry;
}
function pruneImagePreloadCache(): void {
while (imagePreloadCache.size > MAX_IMAGE_PRELOAD_CACHE_ENTRIES) {
const oldestUrl = imagePreloadCache.keys().next().value;
if (oldestUrl === undefined) {
return;
}
const entry = imagePreloadCache.get(oldestUrl);
if (entry?.status === 'loading') {
imagePreloadCache.delete(oldestUrl);
imagePreloadCache.set(oldestUrl, entry);
return;
}
imagePreloadCache.delete(oldestUrl);
}
}
const failedAt = new Map<string, number>();
function scheduleImagePreload(callback: () => void): void {
if (typeof window === 'undefined') {
@@ -59,54 +20,41 @@ function scheduleImagePreload(callback: () => void): void {
window.setTimeout(callback, 0);
}
export function preloadExpressionImage(url: string | null | undefined): Promise<void> | null {
if (!url || typeof window === 'undefined' || typeof Image === 'undefined') {
return null;
function recordFailure(url: string): void {
if (failedAt.size >= MAX_TRACKED_FAILURES) {
failedAt.clear();
}
const existing = imagePreloadCache.get(url);
if (existing) {
if (shouldRetryFailedEntry(existing)) {
imagePreloadCache.delete(url);
} else {
touchEntry(url, existing);
return existing.promise;
}
}
const image = new Image();
const entry: ImagePreloadEntry = {
status: 'loading',
promise: null,
lastUsed: Date.now(),
failedAt: null,
};
entry.promise = new Promise<void>((resolve) => {
image.onload = () => {
image.onload = null;
image.onerror = null;
entry.status = 'loaded';
entry.promise = null;
entry.failedAt = null;
resolve();
};
image.onerror = () => {
image.onload = null;
image.onerror = null;
entry.status = 'error';
entry.promise = null;
entry.failedAt = Date.now();
resolve();
};
});
try {
image.decoding = 'async';
} catch {}
image.src = url;
imagePreloadCache.set(url, entry);
pruneImagePreloadCache();
return entry.promise;
failedAt.set(url, Date.now());
}
export function preloadExpressionImages(urls: ReadonlyArray<string | null | undefined>): void {
function isInFailureBackoff(url: string): boolean {
const failedTime = failedAt.get(url);
if (failedTime === undefined) {
return false;
}
if (Date.now() - failedTime < IMAGE_PRELOAD_ERROR_RETRY_DELAY_MS) {
return true;
}
failedAt.delete(url);
return false;
}
function preloadExpressionImage(url: string): void {
if (hasImage(url) || isInFailureBackoff(url)) {
return;
}
loadImage(
url,
() => {
failedAt.delete(url);
},
() => {
recordFailure(url);
},
);
}
function preloadExpressionImages(urls: ReadonlyArray<string | null | undefined>): void {
if (urls.length === 0) {
return;
}
@@ -127,25 +75,8 @@ export function preloadExpressionImages(urls: ReadonlyArray<string | null | unde
scheduleImagePreload(preloadNextBatch);
}
export function useExpressionImagePreload(url: string | null | undefined): void {
useEffect(() => {
void preloadExpressionImage(url);
}, [url]);
}
export function useExpressionImagesPreload(urls: ReadonlyArray<string | null | undefined>): void {
useEffect(() => {
preloadExpressionImages(urls);
}, [urls]);
}
export function getExpressionImagePreloadStatus(url: string | null | undefined): ImagePreloadStatus | null {
if (!url) {
return null;
}
return imagePreloadCache.get(url)?.status ?? null;
}
export function clearExpressionImagePreloadCacheForTests(): void {
imagePreloadCache.clear();
}