mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(app): retry failed image loads with escalating delay and connectivity waits (#1758)
This commit is contained in:
1 parent
362a89f2b2
commit
9f78b3d9a6
2 files changed
+125
No files matched your search
@@ -4,6 +4,8 @@
|
|||||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
const IMAGE_RETRY_CEILING_MS = 20_000;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
ImageCacheUtils._clearForTests();
|
ImageCacheUtils._clearForTests();
|
||||||
});
|
});
|
||||||
@@ -30,3 +32,69 @@ describe('loadImage rejection path', () => {
|
|||||||
expect(() => dispose()).not.toThrow();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,9 @@ interface PendingImageLoad {
|
|||||||
subscribers: Set<PendingImageSubscriber>;
|
subscribers: Set<PendingImageSubscriber>;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
timeoutId: number;
|
timeoutId: number;
|
||||||
|
failedAttempts: number;
|
||||||
|
retryTimeoutId: number;
|
||||||
|
connectivityListener: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_CACHE_ENTRIES = 500;
|
const MAX_CACHE_ENTRIES = 500;
|
||||||
@@ -37,6 +40,9 @@ const MAX_PENDING_IMAGE_CALLBACKS_PER_LOAD = 256;
|
|||||||
const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
|
const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
|
||||||
const IMAGE_LOAD_TIMEOUT_MS = 30_000;
|
const IMAGE_LOAD_TIMEOUT_MS = 30_000;
|
||||||
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
|
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
|
||||||
|
const IMAGE_RETRY_ATTEMPT_LIMIT = 5;
|
||||||
|
const IMAGE_RETRY_INITIAL_DELAY_MS = 500;
|
||||||
|
const IMAGE_RETRY_MAX_DELAY_MS = 15_000;
|
||||||
|
|
||||||
const getDimensionByteSize = (width: number, height: number): number | null => {
|
const getDimensionByteSize = (width: number, height: number): number | null => {
|
||||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
||||||
@@ -169,10 +175,57 @@ function releaseActiveImageLoad(): void {
|
|||||||
if (activeImageLoadCount < 0) throw new Error('Active image load count became negative');
|
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 {
|
||||||
|
return Math.min(IMAGE_RETRY_INITIAL_DELAY_MS * 2 ** (failedAttempts - 1), IMAGE_RETRY_MAX_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleImageLoadRetry(src: string, pendingLoad: PendingImageLoad): boolean {
|
||||||
|
if (pendingLoad.failedAttempts >= IMAGE_RETRY_ATTEMPT_LIMIT) return false;
|
||||||
|
pendingLoad.failedAttempts += 1;
|
||||||
|
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 {
|
function detachPendingImageLoad(src: string, pendingLoad: PendingImageLoad): boolean {
|
||||||
if (pendingImageLoads.get(src) !== pendingLoad) return false;
|
if (pendingImageLoads.get(src) !== pendingLoad) return false;
|
||||||
pendingImageLoads.delete(src);
|
pendingImageLoads.delete(src);
|
||||||
window.clearTimeout(pendingLoad.timeoutId);
|
window.clearTimeout(pendingLoad.timeoutId);
|
||||||
|
clearRetryState(pendingLoad);
|
||||||
if (pendingLoad.image != null) {
|
if (pendingLoad.image != null) {
|
||||||
pendingLoad.image.onload = null;
|
pendingLoad.image.onload = null;
|
||||||
pendingLoad.image.onerror = null;
|
pendingLoad.image.onerror = null;
|
||||||
@@ -240,6 +293,7 @@ function activatePendingImageLoad(src: string, pendingLoad: PendingImageLoad): v
|
|||||||
image.onerror = () => {
|
image.onerror = () => {
|
||||||
if (pendingImageLoads.get(src) !== pendingLoad) return;
|
if (pendingImageLoads.get(src) !== pendingLoad) return;
|
||||||
imageCache.delete(src);
|
imageCache.delete(src);
|
||||||
|
if (scheduleImageLoadRetry(src, pendingLoad)) return;
|
||||||
settlePendingImageLoad(src, pendingLoad, false, null);
|
settlePendingImageLoad(src, pendingLoad, false, null);
|
||||||
};
|
};
|
||||||
image.src = src;
|
image.src = src;
|
||||||
@@ -259,6 +313,9 @@ function createPendingImageLoad(src: string): PendingImageLoad {
|
|||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
active: false,
|
active: false,
|
||||||
timeoutId: 0,
|
timeoutId: 0,
|
||||||
|
failedAttempts: 0,
|
||||||
|
retryTimeoutId: 0,
|
||||||
|
connectivityListener: null,
|
||||||
};
|
};
|
||||||
pendingLoad.timeoutId = window.setTimeout(() => {
|
pendingLoad.timeoutId = window.setTimeout(() => {
|
||||||
settlePendingImageLoad(src, pendingLoad, false, null);
|
settlePendingImageLoad(src, pendingLoad, false, null);
|
||||||
|
|||||||
Reference in new issue
Block a user