Files
suwayomi-material-you-webui/src/base/components/SpinnerImage.tsx
schroda 60e087d79a Fix aborting image requests
Regression 9ed05f3fc0

Due to the change to check if an image is cached, which is done async, it was possible that the image request was aborted before the request actually got queued.

In that case, the abort was called against the "noop" function and did not actually abort the triggered request.
Thus, after the image got queued, we have to optionally abort it again to ensure that the request really gets aborted.
2026-05-02 21:23:50 +02:00

247 lines
8.6 KiB
TypeScript

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { Ref } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { STABLE_EMPTY_OBJECT } from '@/base/Base.constants.ts';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
import RefreshIcon from '@mui/icons-material/Refresh';
import ImageIcon from '@mui/icons-material/Image';
import type { SxProps, Theme } from '@mui/material/styles';
import { useLingui } from '@lingui/react/macro';
import { usePrevious } from '@mantine/hooks';
import type { ImageRequest } from '@/lib/requests/RequestManager.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import type { Priority } from '@/lib/Queue.ts';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { useIntersectionObserver } from '@/base/hooks/useIntersectionObserver.tsx';
import { noOp } from '@/lib/HelperFunctions.ts';
export interface SpinnerImageProps {
shouldLoad?: boolean;
src: string;
alt: string;
spinnerStyle?: SxProps<Theme> & { small?: boolean };
imgStyle?: SxProps<Theme>;
hideImgStyle?: Omit<SxProps<Theme>, 'accentColor'>;
onLoad?: () => void;
onError?: () => void;
shouldDecode?: boolean;
useFetchApi?: boolean;
disableCors?: boolean;
ignoreQueue?: boolean;
priority?: Priority;
retryKeyPrefix?: string;
ref?: Ref<HTMLImageElement | HTMLDivElement | null>;
}
export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
const {
shouldLoad = true,
shouldDecode,
useFetchApi,
disableCors,
ignoreQueue,
src,
alt,
onLoad,
onError,
spinnerStyle: { small, ...spinnerStyle } = STABLE_EMPTY_OBJECT,
imgStyle,
hideImgStyle,
priority,
retryKeyPrefix,
} = props;
const { t } = useLingui();
const loadingIndicatorRef = useRef<HTMLDivElement | null>(null);
const showMissingImageIcon = !src.length;
const [imageSourceUrl, setImageSourceUrl] = useState<string>();
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
const [isLoading, setIsLoading] = useState<boolean>();
const [hasError, setHasError] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const previousSrc = usePrevious(src);
const previousImgLoadRetryKey = usePrevious(imgLoadRetryKey);
const previousRetryKeyPrefix = usePrevious(retryKeyPrefix);
const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
setIsLoading(loading);
setHasError(error);
if (error && !loading && !aborted) {
onError?.();
}
if (!loading && !error && !aborted) {
onLoad?.();
}
};
useIntersectionObserver(
loadingIndicatorRef,
useCallback((entries) => setIsVisible(entries[0].isIntersecting), []),
);
useEffect(() => {
const didSrcChange = previousSrc !== src;
const isLoadedAndSrcUnchanged = !!imageSourceUrl && !didSrcChange;
const isLocalRetry =
hasError && previousImgLoadRetryKey !== undefined && previousImgLoadRetryKey !== imgLoadRetryKey;
const isGlobalRetry =
hasError && previousRetryKeyPrefix !== undefined && previousRetryKeyPrefix !== retryKeyPrefix;
const isRetry = isLocalRetry || isGlobalRetry;
const finalShouldLoad = shouldLoad || isRetry;
if (showMissingImageIcon || !finalShouldLoad || isLoadedAndSrcUnchanged) {
return () => {};
}
let isAborted = false;
let imageRequest: ImageRequest = {
response: Promise.resolve(''),
cleanup: noOp,
abortRequest: noOp,
fromCache: false,
};
const abortRequest = () => {
isAborted = true;
imageRequest.cleanup();
imageRequest.abortRequest(new Error('Component was unmounted'));
};
const fetchImage = async () => {
try {
imageRequest = await requestManager.requestImage(src, {
priority,
shouldDecode,
useFetchApi,
disableCors,
ignoreQueue,
});
// In case the request got aborted before it was queued, the abort was called against the "default noop" function and did nothing.
// Thus, abort again to ensure that the actual queued request gets aborted.
if (isAborted) {
abortRequest();
}
if (!imageRequest.fromCache) {
updateImageState(true);
}
const image = await imageRequest.response;
updateImageState(false);
setImageSourceUrl(image);
} catch (e) {
const wasAborted =
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
updateImageState(false, !wasAborted, wasAborted);
}
};
fetchImage().catch(() => {});
return () => {
abortRequest();
};
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
return (
<>
{showMissingImageIcon ? (
<Stack
ref={ref}
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
background: (theme) => theme.palette.background.default,
...spinnerStyle,
}}
>
<ImageIcon fontSize="large" />
</Stack>
) : (
<Box
component="img"
key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`}
sx={[
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
applyStyles(!imageSourceUrl || isLoading || hasError, {
...hideImgStyle,
...applyStyles(!hideImgStyle, {
display: 'none',
}),
}),
]}
ref={ref}
crossOrigin={disableCors ? undefined : 'anonymous'}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
{(!!isLoading || (src && !imageSourceUrl) || hasError) && (
<Stack
ref={loadingIndicatorRef}
sx={{
height: '100%',
justifyContent: 'center',
alignItems: 'center',
...spinnerStyle,
}}
>
<Stack
sx={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isVisible && !!isLoading && <CircularProgress thickness={5} />}
{hasError && isLoading === false && (
<>
<BrokenImageIcon />
<Button
startIcon={!small && <RefreshIcon />}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setImgLoadRetryKey((prevState) => (prevState + 1) % 100);
}}
size={small ? 'small' : 'large'}
>
{small ? <RefreshIcon /> : t`Retry`}
</Button>
</>
)}
</Stack>
</Stack>
)}
</>
);
};