mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
perf(app): preload picker images through the shared image cache (#1923)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user