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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user