Abort image requests on unmount (#624)

This commit is contained in:
schroda
2024-03-01 01:17:23 +01:00
committed by GitHub
parent 900a6c1609
commit f852ce70e7
2 changed files with 56 additions and 12 deletions

View File

@@ -14,6 +14,9 @@ import { Theme, SxProps, Stack, Button } from '@mui/material';
import BrokenImageIcon from '@mui/icons-material/BrokenImage'; import BrokenImageIcon from '@mui/icons-material/BrokenImage';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CanceledError } from 'axios';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
interface IProps { interface IProps {
src: string; src: string;
@@ -32,6 +35,7 @@ export function SpinnerImage(props: IProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [imageSourceUrl, setImageSourceUrl] = useState('');
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0); const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
const [isLoading, setIsLoading] = useState<boolean | undefined>(undefined); const [isLoading, setIsLoading] = useState<boolean | undefined>(undefined);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
@@ -46,11 +50,39 @@ export function SpinnerImage(props: IProps) {
}; };
useEffect(() => { useEffect(() => {
// only activate the loading state in case the image has not been cached yet. const imageRequest = requestManager.requestImage(src);
// otherwise, the loading placeholder will always be visible before the actual image is shown, which looks like flickering
const timeout = setTimeout(() => setIsLoading((prevState) => (prevState === undefined ? true : prevState)), 1); const fetchImage = async () => {
return () => clearTimeout(timeout); try {
}, []); const updateImage = async () => {
const image = await imageRequest.response;
updateImageState(false);
setImageSourceUrl(image);
};
const checkCache = await Promise.race([imageRequest.response, Promise.resolve(false)]);
const isImageCached = !!checkCache;
if (isImageCached) {
await updateImage();
return;
}
updateImageState(true);
await updateImage();
} catch (e) {
const wasAborted = e instanceof CanceledError;
updateImageState(false, !wasAborted);
}
};
fetchImage().catch(defaultPromiseErrorHandler);
return () => {
imageRequest.abortRequest(new Error('Component was unmounted'));
};
}, [imgLoadRetryKey]);
return ( return (
<> <>
@@ -58,7 +90,7 @@ export function SpinnerImage(props: IProps) {
<Box sx={spinnerStyle}> <Box sx={spinnerStyle}>
<Stack height="100%" alignItems="center" justifyContent="center"> <Stack height="100%" alignItems="center" justifyContent="center">
{isLoading && <CircularProgress thickness={5} />} {isLoading && <CircularProgress thickness={5} />}
{hasError && ( {hasError && isLoading === false && (
<> <>
<BrokenImageIcon /> <BrokenImageIcon />
<Button <Button
@@ -66,8 +98,6 @@ export function SpinnerImage(props: IProps) {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
setIsLoading(true);
setHasError(false);
setImgLoadRetryKey((prevState) => (prevState + 1) % 100); setImgLoadRetryKey((prevState) => (prevState + 1) % 100);
}} }}
size="large" size="large"
@@ -79,14 +109,16 @@ export function SpinnerImage(props: IProps) {
</Stack> </Stack>
</Box> </Box>
)} )}
<img <img
key={`${src}_${imgLoadRetryKey}`} key={`${src}_${imgLoadRetryKey}`}
style={{ ...imgStyle, display: isLoading || hasError ? 'none' : imgStyle?.display }} style={{
...imgStyle,
display: !imageSourceUrl || isLoading || hasError ? 'none' : imgStyle?.display,
}}
ref={imgRef} ref={imgRef}
src={src} src={imageSourceUrl}
alt={alt} alt={alt}
onLoad={() => updateImageState(false)}
onError={() => updateImageState(false, true)}
draggable={false} draggable={false}
/> />
</> </>

View File

@@ -746,6 +746,18 @@ export class RequestManager {
return `${this.getValidUrlFor(imageUrl, apiVersion)}`; return `${this.getValidUrlFor(imageUrl, apiVersion)}`;
} }
public requestImage(url: string): { response: Promise<string> } & AbortableRequest {
const { abortRequest, signal } = this.createAbortController();
const response = this.restClient
.fetcher(url, {
checkResponseIsJson: false,
config: { signal, responseType: 'blob' },
})
.then((data) => URL.createObjectURL(data));
return { response, abortRequest };
}
private doRequest<Data, Variables extends OperationVariables = OperationVariables>( private doRequest<Data, Variables extends OperationVariables = OperationVariables>(
method: GQLMethod.QUERY, method: GQLMethod.QUERY,
operation: DocumentNode | TypedDocumentNode<Data, Variables>, operation: DocumentNode | TypedDocumentNode<Data, Variables>,