Show image loading placeholder only when visible

The MUI spinner impacts the cpu usage.
While only a few of them don't matter, rendering multiple of them can impact the cpu usage.

This can be a problem in the reader in case a chapter with a lot of chapters is rendered and a continuous reading mode is being used.
In such a case the cpu usage stays increased until all pages have been loaded
This commit is contained in:
schroda
2025-02-08 19:48:50 +01:00
parent a4e9e8f264
commit a7df8eb0f4

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { useState, useEffect, forwardRef, ForwardedRef } from 'react'; import { useState, useEffect, forwardRef, ForwardedRef, useCallback, useRef } from 'react';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
@@ -19,6 +19,7 @@ import { SxProps, Theme } from '@mui/material/styles';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { Priority } from '@/lib/Queue.ts'; import { Priority } from '@/lib/Queue.ts';
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts'; import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
import { useIntersectionObserver } from '@/modules/core/hooks/useIntersectionObserver.tsx';
interface IProps { interface IProps {
shouldLoad?: boolean; shouldLoad?: boolean;
@@ -42,170 +43,183 @@ interface IProps {
retryKeyPrefix?: string; retryKeyPrefix?: string;
} }
export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef<HTMLImageElement | null>) => { export const SpinnerImage = forwardRef(
const { (props: IProps, imgRef: ForwardedRef<HTMLImageElement | HTMLDivElement | null>) => {
shouldLoad = true, const {
shouldDecode, shouldLoad = true,
useFetchApi,
disableCors,
src,
alt,
onLoad,
onError,
spinnerStyle: { small, ...spinnerStyle } = {},
imgStyle,
hideImgStyle,
priority,
retryKeyPrefix,
} = props;
const { t } = useTranslation();
const showMissingImageIcon = !src.length;
const [imageSourceUrl, setImageSourceUrl] = useState('');
const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
const [isLoading, setIsLoading] = useState<boolean | undefined>(undefined);
const [hasError, setHasError] = useState(false);
const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
setIsLoading(loading);
setHasError(error);
if (error && !loading && !aborted) {
onError?.();
}
if (!loading && !error && !aborted) {
onLoad?.();
}
};
useEffect(() => {
if (showMissingImageIcon || !shouldLoad) {
return () => {};
}
const imageRequest = requestManager.requestImage(src, {
priority,
shouldDecode, shouldDecode,
useFetchApi, useFetchApi,
disableCors, disableCors,
}); src,
let cacheTimeout: NodeJS.Timeout; alt,
onLoad,
onError,
spinnerStyle: { small, ...spinnerStyle } = {},
imgStyle,
hideImgStyle,
priority,
retryKeyPrefix,
} = props;
const fetchImage = async () => { const { t } = useTranslation();
try {
const updateImage = async () => {
const image = await imageRequest.response;
updateImageState(false); const loadingIndicatorRef = useRef<HTMLDivElement | null>(null);
setImageSourceUrl(image);
};
const checkCache = await Promise.race([ const showMissingImageIcon = !src.length;
imageRequest.response,
new Promise((resolve) => {
cacheTimeout = setTimeout(resolve, 50);
}),
]);
const isImageCached = !!checkCache;
if (isImageCached) { const [imageSourceUrl, setImageSourceUrl] = useState('');
await updateImage(); const [imgLoadRetryKey, setImgLoadRetryKey] = useState(0);
return; const [isLoading, setIsLoading] = useState<boolean | undefined>(undefined);
} const [hasError, setHasError] = useState(false);
const [isVisible, setIsVisible] = useState(false);
updateImageState(true); const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
await updateImage(); setIsLoading(loading);
} catch (e) { setHasError(error);
const wasAborted =
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted'); if (error && !loading && !aborted) {
updateImageState(false, !wasAborted, wasAborted); onError?.();
}
if (!loading && !error && !aborted) {
onLoad?.();
} }
}; };
fetchImage().catch(() => {}); useIntersectionObserver(
loadingIndicatorRef,
useCallback((entries) => setIsVisible(entries[0].isIntersecting), []),
);
return () => { useEffect(() => {
imageRequest.cleanup(); if (showMissingImageIcon || !shouldLoad) {
clearTimeout(cacheTimeout); return () => {};
imageRequest.abortRequest(new Error('Component was unmounted')); }
};
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
return ( const imageRequest = requestManager.requestImage(src, {
<> priority,
{showMissingImageIcon ? ( shouldDecode,
<Stack useFetchApi,
ref={imgRef} disableCors,
sx={{ });
height: '100%', let cacheTimeout: NodeJS.Timeout;
alignItems: 'center',
justifyContent: 'center', const fetchImage = async () => {
background: (theme) => theme.palette.background.default, try {
...spinnerStyle, const updateImage = async () => {
}} const image = await imageRequest.response;
>
<ImageIcon fontSize="large" /> updateImageState(false);
</Stack> setImageSourceUrl(image);
) : ( };
<Box
component="img" const checkCache = await Promise.race([
key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`} imageRequest.response,
sx={[ new Promise((resolve) => {
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]), cacheTimeout = setTimeout(resolve, 50);
applyStyles(!imageSourceUrl || isLoading || hasError, {
...hideImgStyle,
...applyStyles(!hideImgStyle, {
display: 'none',
}),
}), }),
]} ]);
ref={imgRef} const isImageCached = !!checkCache;
crossOrigin={disableCors ? undefined : 'anonymous'}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
{(isLoading || (src && !imageSourceUrl) || hasError) && ( if (isImageCached) {
<Stack await updateImage();
sx={{ return;
height: '100%', }
justifyContent: 'center',
alignItems: 'center', updateImageState(true);
...spinnerStyle, await updateImage();
}} } catch (e) {
> const wasAborted =
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
updateImageState(false, !wasAborted, wasAborted);
}
};
fetchImage().catch(() => {});
return () => {
imageRequest.cleanup();
clearTimeout(cacheTimeout);
imageRequest.abortRequest(new Error('Component was unmounted'));
};
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
return (
<>
{showMissingImageIcon ? (
<Stack <Stack
ref={imgRef}
sx={{ sx={{
height: '100%', height: '100%',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
background: (theme) => theme.palette.background.default,
...spinnerStyle,
}} }}
> >
{(isLoading || (src && !imageSourceUrl && !hasError)) && <CircularProgress thickness={5} />} <ImageIcon fontSize="large" />
{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('global.button.retry')}
</Button>
</>
)}
</Stack> </Stack>
</Stack> ) : (
)} <Box
</> component="img"
); key={`${src}_${imgLoadRetryKey}_${retryKeyPrefix}`}
}); sx={[
...(Array.isArray(imgStyle) ? (imgStyle ?? []) : [imgStyle]),
applyStyles(!imageSourceUrl || isLoading || hasError, {
...hideImgStyle,
...applyStyles(!hideImgStyle, {
display: 'none',
}),
}),
]}
ref={imgRef}
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 || (src && !imageSourceUrl && !hasError)) && (
<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('global.button.retry')}
</Button>
</>
)}
</Stack>
</Stack>
)}
</>
);
},
);