From 9f78b3d9a69e06413583c0fe059e230dae2df281 Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 19 Aug 2026 18:25:34 +0200 Subject: [PATCH] feat(app): retry failed image loads with escalating delay and connectivity waits (#1758) --- .../messaging/utils/ImageCacheUtils.test.ts | 68 +++++++++++++++++++ .../messaging/utils/ImageCacheUtils.ts | 57 ++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/fluxer_app/src/features/messaging/utils/ImageCacheUtils.test.ts b/fluxer_app/src/features/messaging/utils/ImageCacheUtils.test.ts index 1eb0e3946..1ada373db 100644 --- a/fluxer_app/src/features/messaging/utils/ImageCacheUtils.test.ts +++ b/fluxer_app/src/features/messaging/utils/ImageCacheUtils.test.ts @@ -4,6 +4,8 @@ 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(); }); @@ -30,3 +32,69 @@ describe('loadImage rejection path', () => { expect(() => dispose()).not.toThrow(); }); }); + +describe('image load retry', () => { + class StubImage { + static instances: Array = []; + 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(); + }); +}); diff --git a/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts b/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts index 2ba4a83bb..8462694a7 100644 --- a/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts +++ b/fluxer_app/src/features/messaging/utils/ImageCacheUtils.ts @@ -24,6 +24,9 @@ interface PendingImageLoad { subscribers: Set; active: boolean; timeoutId: number; + failedAttempts: number; + retryTimeoutId: number; + connectivityListener: (() => void) | null; } 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 IMAGE_LOAD_TIMEOUT_MS = 30_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 => { 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'); } +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 { 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; @@ -240,6 +293,7 @@ function activatePendingImageLoad(src: string, pendingLoad: PendingImageLoad): v image.onerror = () => { if (pendingImageLoads.get(src) !== pendingLoad) return; imageCache.delete(src); + if (scheduleImageLoadRetry(src, pendingLoad)) return; settlePendingImageLoad(src, pendingLoad, false, null); }; image.src = src; @@ -259,6 +313,9 @@ function createPendingImageLoad(src: string): PendingImageLoad { subscribers: new Set(), active: false, timeoutId: 0, + failedAttempts: 0, + retryTimeoutId: 0, + connectivityListener: null, }; pendingLoad.timeoutId = window.setTimeout(() => { settlePendingImageLoad(src, pendingLoad, false, null);