fix(gallery): bound concurrent image fetches and abort them on unmount (#1287)

Hardening for the large-gallery stall. The reporter could not isolate the
cause and neither could I from static reading; these are two defects that
are wrong independently of whether they are the whole story.

Gallery grids are NOT virtualized: a 546-photo event puts 546 PhotoCards
in the DOM, each mounting its own bare fetch. Two problems there: no cap,
and a cleanup that only set a flag while the request kept running.

withImageFetchSlot now holds requests to six in flight, with the BODY read
inside the slot — fetch resolves on headers, so releasing there would have
bounded header round-trips and nothing else. Teardown aborts via
AbortController.

A request queued indefinitely is PENDING, not failed, which is why the
failure left no console error, no failed request and nothing in the
backend log.

Review follow-ups: three tiers (current lightbox slide > neighbour
prefetch > grid thumbnails), because a single FIFO put the image the user
just clicked behind hundreds of thumbnails; and a synchronous throw now
releases its slot instead of permanently draining the pool.

If it recurs, capture performance.getEntriesByType('resource') for the
stalled thumbnails — a queued request shows responseStart === 0.

Relates to #1287
This commit is contained in:
Paul Nothaft
2026-09-04 14:26:48 +02:00
committed by GitHub
parent 8f98f6bdec
commit 4afe7a6f08
4 changed files with 502 additions and 9 deletions
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { buildResourceUrl } from '../../utils/url';
import { withImageFetchSlot } from '../../utils/imageFetchQueue';
import {
getActiveGallerySlug,
getGalleryToken,
@@ -30,6 +31,16 @@ interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImage
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
onLoad?: () => void;
/**
* Priority in the shared fetch queue (#1287). NOT the native `fetchPriority`
* DOM attribute, which stays available on this component and takes
* "low"|"high"|"auto" — hence the distinct name.
*
* 'high' the image the user is looking at now (current lightbox slide)
* 'prefetch' one interaction away (lightbox neighbours)
* 'normal' grid thumbnails
*/
queuePriority?: 'high' | 'prefetch' | 'normal';
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -56,6 +67,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
protectionLevel,
useEnhancedProtection,
onLoad,
queuePriority = 'normal',
...props
}) => {
const unusedProps = {
@@ -108,6 +120,10 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
useEffect(() => {
let aborted = false;
const objectUrls: string[] = [];
// #1287 — the previous cleanup only set a flag. The request itself kept
// running, holding a connection slot for a tile that is no longer on
// screen, which on a several-hundred-photo gallery is most of them.
const controller = new AbortController();
// Determine which token to use based on context
if (!src) {
@@ -159,17 +175,35 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
}
}
const response = await fetch(fullImageUrl, {
credentials: 'include',
headers: Object.keys(headers).length ? headers : undefined,
});
// Queued (#1287). Without a cap, a 546-photo grid hands the browser
// several hundred simultaneous fetches and some never come back —
// pending forever, so nothing is logged and nothing is "failed".
//
// The BODY read has to happen inside the slot. `fetch` resolves as soon
// as the headers arrive, so releasing there would free the slot while
// the image bytes are still streaming on that connection — the cap
// would bound header round-trips and nothing else, which is not the
// workload that stalls a large gallery.
const blob = await withImageFetchSlot(async () => {
const response = await fetch(fullImageUrl, {
credentials: 'include',
headers: Object.keys(headers).length ? headers : undefined,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
return await response.blob();
}, { priority: queuePriority });
const objectUrl = URL.createObjectURL(blob);
// The effect may have been torn down while this was in flight. Revoke
// immediately rather than pushing onto an array nobody will read again.
if (aborted) {
URL.revokeObjectURL(objectUrl);
throw new Error('aborted');
}
objectUrls.push(objectUrl);
return objectUrl;
};
@@ -182,6 +216,10 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
setError(false);
}
} catch (err) {
// Torn down mid-flight (#1287): the abort is expected, not a failure.
// Returning here also stops the fallback below from firing a second
// request against an already-aborted signal.
if (aborted) return;
setIsLoading(false);
if (fallbackSrc && fallbackSrc !== src) {
try {
@@ -211,10 +249,13 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// Cleanup function
return () => {
aborted = true;
// Free the connection slot rather than leaving the request to run for
// a tile that is gone (#1287).
controller.abort();
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, fallbackSrc, slug]);
}, [src, fallbackSrc, slug, queuePriority]);
// Effect to draw to canvas when image is loaded and canvas rendering is enabled
useEffect(() => {