mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
perf(app): load images immediately instead of queueing four at a time (#1855)
This commit is contained in:
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');
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user