Abort image requests on unmount (#624)
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
Reference in New Issue
Block a user