fix(images): cancel in-flight preloads and skip abandoned requests

LazyImage and LazyBackgroundImage preload through a detached Image and their
cleanup only set a cancelled flag, so the request kept downloading after
unmount. Navigating away from a grid left dozens of downloads occupying the
browser's six connections and the server's I/O, starving the page the user
actually opened.

Clear the handlers and reset src on cleanup, which is what aborts the request,
and have the image proxy return early when the client has already gone rather
than paying a mount round trip — or a 10s CDN fetch — for nobody.
This commit is contained in:
Darkraise
2026-08-12 01:56:11 +07:00
parent 246e58f4ac
commit fb8fcd390a
3 changed files with 124 additions and 0 deletions
+19
View File
@@ -60,6 +60,11 @@ function getRealBasePath(basePath: string): Promise<string> {
return resolving;
}
/** True once the client has gone, so remaining work would be wasted. */
function clientGone(res: Response): boolean {
return res.req.destroyed || res.writableEnded;
}
async function serveFileWithFallback(
localPath: string,
relativePath: string,
@@ -69,6 +74,14 @@ async function serveFileWithFallback(
allowedBasePath: string
) {
try {
// A grid of thumbnails that the user navigated away from leaves dozens of
// abandoned requests. Serving them costs a round trip each on a network
// mount, starving the requests the user is actually waiting for.
if (clientGone(res)) {
logger.debug(`[Proxy] Client gone before serving: ${relativePath}`);
return;
}
// Async, not existsSync: a synchronous stat blocks the event loop for the
// full round trip, which is milliseconds per image on a network mount.
const localFileExists = await fsPromises
@@ -103,6 +116,12 @@ async function serveFileWithFallback(
logger.debug(`[Proxy] Local file not found, trying external CDN: ${relativePath}`);
// Never start a 10-second CDN fetch for a client that has already left.
if (clientGone(res)) {
logger.debug(`[Proxy] Client gone before CDN fallback: ${relativePath}`);
return;
}
for (const baseUrl of externalBaseUrls) {
try {
const externalUrl = `${baseUrl.trim()}/${relativePath}`;
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import { LazyImage } from './LazyImage';
/**
* jsdom has no IntersectionObserver, which is the branch LazyImage falls back
* on by treating the image as immediately in view — exactly what we want here.
*/
class FakeImage {
static instances: FakeImage[] = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
private _src = '';
constructor() {
FakeImage.instances.push(this);
}
get src(): string {
return this._src;
}
set src(value: string) {
this._src = value;
}
}
const originalImage = globalThis.Image;
const originalObserver = globalThis.IntersectionObserver;
/**
* The shared test setup stubs IntersectionObserver with one that never fires,
* so nothing ever enters view. This one reports intersection on observe, which
* is the state the preload path needs.
*/
class IntersectingObserver {
constructor(private readonly callback: IntersectionObserverCallback) {}
observe(target: Element): void {
this.callback(
[{ isIntersecting: true, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
);
}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => {
FakeImage.instances = [];
globalThis.Image = FakeImage as unknown as typeof Image;
globalThis.IntersectionObserver = IntersectingObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
globalThis.Image = originalImage;
globalThis.IntersectionObserver = originalObserver;
vi.restoreAllMocks();
});
describe('LazyImage preload cleanup', () => {
it('aborts the in-flight preload when the component unmounts', () => {
const { unmount } = render(<LazyImage src="/api/proxy/images/game.png" alt="Game" />);
const preload = FakeImage.instances[0];
expect(preload).toBeDefined();
expect(preload.src).toBe('/api/proxy/images/game.png');
unmount();
// Clearing src is what actually cancels the request; a detached Image would
// otherwise keep downloading after the page it belonged to is gone.
expect(preload.src).toBe('');
expect(preload.onload).toBeNull();
expect(preload.onerror).toBeNull();
});
it('aborts the previous preload when src changes', () => {
const { rerender } = render(<LazyImage src="/api/proxy/images/first.png" alt="First" />);
const first = FakeImage.instances[0];
rerender(<LazyImage src="/api/proxy/images/second.png" alt="Second" />);
expect(first.src).toBe('');
expect(FakeImage.instances[1].src).toBe('/api/proxy/images/second.png');
});
});
@@ -1,6 +1,21 @@
import { useState, useEffect, useRef, ImgHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/**
* Stop a preload that is still in flight.
*
* A detached Image keeps downloading after its component unmounts — nothing
* removes it from a document, so the browser has no reason to abort. Clearing
* src is what actually cancels the request; without it, navigating away from a
* grid leaves every thumbnail downloading, occupying the six connections the
* next page needs and keeping the server busy on results nobody will read.
*/
function abortImageLoad(img: HTMLImageElement): void {
img.onload = null;
img.onerror = null;
img.src = '';
}
interface LazyImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'placeholder'> {
src: string;
alt: string;
@@ -100,6 +115,7 @@ export function LazyImage({
return () => {
cancelled = true;
abortImageLoad(img);
};
}, [isInView, src]);
@@ -218,6 +234,7 @@ export function LazyBackgroundImage({
img.src = src;
return () => {
cancelled = true;
abortImageLoad(img);
};
}, [isInView, src]);