Remove "forwardRef" usage

This commit is contained in:
schroda
2025-09-24 00:51:13 +02:00
parent bcb5e391f0
commit a2d7091712
19 changed files with 1055 additions and 1101 deletions

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useState, useEffect, forwardRef, ForwardedRef, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, Ref } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
@@ -41,185 +41,185 @@ export interface SpinnerImageProps {
priority?: Priority;
retryKeyPrefix?: string;
ref?: Ref<HTMLImageElement | HTMLDivElement | null>;
}
export const SpinnerImage = forwardRef(
(props: SpinnerImageProps, imgRef: ForwardedRef<HTMLImageElement | HTMLDivElement | null>) => {
const {
shouldLoad = true,
export const SpinnerImage = ({ ref, ...props }: SpinnerImageProps) => {
const {
shouldLoad = true,
shouldDecode,
useFetchApi,
disableCors,
src,
alt,
onLoad,
onError,
spinnerStyle: { small, ...spinnerStyle } = {},
imgStyle,
hideImgStyle,
priority,
retryKeyPrefix,
} = props;
const { t } = useTranslation();
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 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(() => {
if (showMissingImageIcon || !shouldLoad) {
return () => {};
}
const imageRequest = requestManager.requestImage(src, {
priority,
shouldDecode,
useFetchApi,
disableCors,
src,
alt,
onLoad,
onError,
spinnerStyle: { small, ...spinnerStyle } = {},
imgStyle,
hideImgStyle,
priority,
retryKeyPrefix,
} = props;
});
let cacheTimeout: NodeJS.Timeout;
const { t } = useTranslation();
const fetchImage = async () => {
try {
const updateImage = async () => {
const image = await imageRequest.response;
const loadingIndicatorRef = useRef<HTMLDivElement | null>(null);
updateImageState(false);
setImageSourceUrl(image);
};
const showMissingImageIcon = !src.length;
const checkCache = await Promise.race([
imageRequest.response,
new Promise((resolve) => {
cacheTimeout = setTimeout(resolve, 50);
}),
]);
const isImageCached = !!checkCache;
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);
if (isImageCached) {
await updateImage();
return;
}
const updateImageState = (loading: boolean, error: boolean = false, aborted: boolean = false) => {
setIsLoading(loading);
setHasError(error);
if (error && !loading && !aborted) {
onError?.();
}
if (!loading && !error && !aborted) {
onLoad?.();
updateImageState(true);
await updateImage();
} catch (e) {
const wasAborted =
e instanceof Error && (e.name === 'AbortError' || e.message === 'Component was unmounted');
updateImageState(false, !wasAborted, wasAborted);
}
};
useIntersectionObserver(
loadingIndicatorRef,
useCallback((entries) => setIsVisible(entries[0].isIntersecting), []),
);
fetchImage().catch(() => {});
useEffect(() => {
if (showMissingImageIcon || !shouldLoad) {
return () => {};
}
return () => {
imageRequest.cleanup();
clearTimeout(cacheTimeout);
imageRequest.abortRequest(new Error('Component was unmounted'));
};
}, [src, imgLoadRetryKey, retryKeyPrefix, showMissingImageIcon, shouldLoad]);
const imageRequest = requestManager.requestImage(src, {
priority,
shouldDecode,
useFetchApi,
disableCors,
});
let cacheTimeout: NodeJS.Timeout;
const fetchImage = async () => {
try {
const updateImage = async () => {
const image = await imageRequest.response;
updateImageState(false);
setImageSourceUrl(image);
};
const checkCache = await Promise.race([
imageRequest.response,
new Promise((resolve) => {
cacheTimeout = setTimeout(resolve, 50);
}),
]);
const isImageCached = !!checkCache;
if (isImageCached) {
await updateImage();
return;
}
updateImageState(true);
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
ref={imgRef}
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',
}),
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={imgRef}
crossOrigin={disableCors ? undefined : 'anonymous'}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
}),
]}
ref={ref}
crossOrigin={disableCors ? undefined : 'anonymous'}
src={imageSourceUrl}
alt={alt}
draggable={false}
/>
)}
{(isLoading || (src && !imageSourceUrl) || hasError) && (
{(isLoading || (src && !imageSourceUrl) || hasError) && (
<Stack
ref={loadingIndicatorRef}
sx={{
height: '100%',
justifyContent: 'center',
alignItems: 'center',
...spinnerStyle,
}}
>
<Stack
ref={loadingIndicatorRef}
sx={{
height: '100%',
justifyContent: 'center',
alignItems: 'center',
...spinnerStyle,
justifyContent: 'center',
}}
>
<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>
{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>
)}
</>
);
};

View File

@@ -8,25 +8,22 @@
import Button, { ButtonProps } from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import { ForwardedRef, forwardRef } from 'react';
export const CustomButton = forwardRef(
<C extends React.ElementType>(
{ children, ...props }: ButtonProps<C, { component?: C }>,
ref: ForwardedRef<HTMLButtonElement | null>,
) => (
<Button ref={ref} {...props}>
<Stack
direction="row"
sx={{
alignItems: 'center',
justifyContent: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{children}
</Stack>
</Button>
),
export const CustomButton = <C extends React.ElementType>({
children,
...props
}: ButtonProps<C, { component?: C }>) => (
<Button {...props}>
<Stack
direction="row"
sx={{
alignItems: 'center',
justifyContent: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{children}
</Stack>
</Button>
);

View File

@@ -7,23 +7,19 @@
*/
import Button, { ButtonProps } from '@mui/material/Button';
import { ForwardedRef, forwardRef } from 'react';
export const CustomButtonIcon = forwardRef(
<C extends React.ElementType>(
{ children, ...props }: ButtonProps<C, { component?: C }>,
ref: ForwardedRef<HTMLButtonElement | null>,
) => (
<Button
ref={ref}
{...props}
sx={{
minWidth: 'unset',
px: '10px',
...props.sx,
}}
>
{children}
</Button>
),
export const CustomButtonIcon = <C extends React.ElementType>({
children,
...props
}: ButtonProps<C, { component?: C }>) => (
<Button
{...props}
sx={{
minWidth: 'unset',
px: '10px',
...props.sx,
}}
>
{children}
</Button>
);

View File

@@ -7,7 +7,7 @@
*/
import { closeSnackbar, CustomContentProps, SnackbarContent, VariantType } from 'notistack';
import { ForwardedRef, forwardRef, Fragment, memo } from 'react';
import { ForwardedRef, Fragment, memo } from 'react';
import Alert from '@mui/material/Alert';
import AlertTitle from '@mui/material/AlertTitle';
import Button from '@mui/material/Button';
@@ -30,88 +30,84 @@ const SNACKBAR_VARIANT_TO_TRANSLATION_KEY: Record<VariantType, TranslationKey> =
};
export const SnackbarWithDescription = memo(
forwardRef(
(
{
id,
message,
description,
variant,
action,
}: CustomContentProps & {
// eslint-disable-next-line react/no-unused-prop-types
description?: string;
},
ref: ForwardedRef<HTMLDivElement>,
) => {
const { t } = useTranslation();
const theme = useTheme();
({
id,
message,
description,
variant,
action,
ref,
}: CustomContentProps & {
description?: string;
ref?: ForwardedRef<HTMLDivElement>;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const severity = variant === 'default' ? 'info' : variant;
const finalAction = typeof action === 'function' ? action(id) : action;
const severity = variant === 'default' ? 'info' : variant;
const finalAction = typeof action === 'function' ? action(id) : action;
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(description);
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(description);
const finalDescription = isGraphqlException ? graphqlError : description;
const isDescriptionTooLong = (finalDescription?.length ?? 0) > MAX_DESCRIPTION_LENGTH;
const actualDescription = isDescriptionTooLong
? finalDescription?.slice(0, MAX_DESCRIPTION_LENGTH)
: finalDescription;
const finalDescription = isGraphqlException ? graphqlError : description;
const isDescriptionTooLong = (finalDescription?.length ?? 0) > MAX_DESCRIPTION_LENGTH;
const actualDescription = isDescriptionTooLong
? finalDescription?.slice(0, MAX_DESCRIPTION_LENGTH)
: finalDescription;
const TitleComponent = actualDescription?.length ? AlertTitle : Fragment;
const TitleComponent = actualDescription?.length ? AlertTitle : Fragment;
return (
<SnackbarContent ref={ref}>
<Alert
elevation={1}
severity={severity}
action={finalAction}
sx={{
wordBreak: 'break-word',
minWidth: '300px',
[theme.breakpoints.down(MediaQuery.MOBILE_WIDTH)]: {
maxWidth: '100vw',
},
[theme.breakpoints.between(MediaQuery.MOBILE_WIDTH, MediaQuery.TABLET_WIDTH)]: {
maxWidth: '75vw',
},
[theme.breakpoints.up(MediaQuery.TABLET_WIDTH)]: {
maxWidth: '50vw',
},
}}
onClose={() => closeSnackbar(id)}
>
<TitleComponent>{message}</TitleComponent>
{actualDescription}
{isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? (
<Button
onClick={() => {
awaitConfirmation({
title:
typeof message === 'string'
? message
: t(SNACKBAR_VARIANT_TO_TRANSLATION_KEY[variant]),
message: description ?? '',
actions: {
cancel: { show: false },
confirm: { title: t('global.label.close') },
},
}).catch(
defaultPromiseErrorHandler(
`SnackbarWithDescription: ${id} - ${message} - ${description}`,
),
);
}}
size="small"
>
{t('global.button.show_more')}
</Button>
) : (
''
)}
</Alert>
</SnackbarContent>
);
},
),
return (
<SnackbarContent ref={ref}>
<Alert
elevation={1}
severity={severity}
action={finalAction}
sx={{
wordBreak: 'break-word',
minWidth: '300px',
[theme.breakpoints.down(MediaQuery.MOBILE_WIDTH)]: {
maxWidth: '100vw',
},
[theme.breakpoints.between(MediaQuery.MOBILE_WIDTH, MediaQuery.TABLET_WIDTH)]: {
maxWidth: '75vw',
},
[theme.breakpoints.up(MediaQuery.TABLET_WIDTH)]: {
maxWidth: '50vw',
},
}}
onClose={() => closeSnackbar(id)}
>
<TitleComponent>{message}</TitleComponent>
{actualDescription}
{isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? (
<Button
onClick={() => {
awaitConfirmation({
title:
typeof message === 'string'
? message
: t(SNACKBAR_VARIANT_TO_TRANSLATION_KEY[variant]),
message: description ?? '',
actions: {
cancel: { show: false },
confirm: { title: t('global.label.close') },
},
}).catch(
defaultPromiseErrorHandler(
`SnackbarWithDescription: ${id} - ${message} - ${description}`,
),
);
}}
size="small"
>
{t('global.button.show_more')}
</Button>
) : (
''
)}
</Alert>
</SnackbarContent>
);
},
);

View File

@@ -13,7 +13,7 @@
import ListItemIcon from '@mui/material/ListItemIcon';
import MenuItem, { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
import { SxProps, Theme } from '@mui/material/styles';
import React, { forwardRef, RefObject } from 'react';
import React, { Ref } from 'react';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
@@ -27,25 +27,31 @@ type IconMenuItemProps = {
renderLabel?: () => React.ReactNode;
LeftIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
ref?: RefObject<HTMLLIElement | null>;
ref?: Ref<HTMLLIElement | null>;
RightIcon?: OverridableComponent<SvgIconTypeMap> & { muiName: string };
sx?: SxProps<Theme>;
};
export const IconMenuItem = forwardRef<HTMLLIElement, IconMenuItemProps>(
({ MenuItemProps, className, label, LeftIcon, renderLabel, RightIcon, ...props }, ref) => (
<MenuItem {...MenuItemProps} ref={ref} className={className} {...props}>
{LeftIcon && (
<ListItemIcon>
<LeftIcon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>{label}</ListItemText>
{RightIcon && (
<ListItemIcon style={{ minWidth: 0 }}>
<RightIcon fontSize="small" />
</ListItemIcon>
)}
</MenuItem>
),
export const IconMenuItem = ({
MenuItemProps,
className,
label,
LeftIcon,
renderLabel,
RightIcon,
...props
}: IconMenuItemProps) => (
<MenuItem {...MenuItemProps} className={className} {...props}>
{LeftIcon && (
<ListItemIcon>
<LeftIcon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>{label}</ListItemText>
{RightIcon && (
<ListItemIcon style={{ minWidth: 0 }}>
<RightIcon fontSize="small" />
</ListItemIcon>
)}
</MenuItem>
);

View File

@@ -16,7 +16,6 @@ import Menu, { MenuProps as MuiMenuProps } from '@mui/material/Menu';
import { MenuItemProps as MuiMenuItemProps } from '@mui/material/MenuItem';
import {
ElementType,
forwardRef,
HTMLAttributes,
KeyboardEvent,
FocusEvent,
@@ -25,6 +24,7 @@ import {
RefAttributes,
useRef,
useState,
Ref,
} from 'react';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
@@ -51,9 +51,10 @@ export type NestedMenuItemProps = Omit<MuiMenuItemProps, 'button'> & {
ContainerProps?: HTMLAttributes<HTMLElement> & RefAttributes<HTMLElement>;
MenuProps?: Partial<Omit<MuiMenuProps, 'children'>>;
button?: true | undefined;
ref?: Ref<HTMLLIElement | null>;
};
const NestedMenuItem = forwardRef<HTMLLIElement | null, NestedMenuItemProps>((props, ref) => {
export const NestedMenuItem = ({ ref, ...props }: NestedMenuItemProps) => {
const {
parentMenuOpen,
label,
@@ -229,7 +230,4 @@ const NestedMenuItem = forwardRef<HTMLLIElement | null, NestedMenuItemProps>((pr
</Menu>
</Box>
);
});
NestedMenuItem.displayName = 'NestedMenuItem';
export { NestedMenuItem };
};

View File

@@ -8,7 +8,6 @@
import Tabs, { TabsProps } from '@mui/material/Tabs';
import { styled } from '@mui/material/styles';
import { ForwardedRef, forwardRef } from 'react';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
const StyledTabsMenu = styled(Tabs)(({ theme }) => ({
@@ -24,23 +23,20 @@ const StyledTabsMenu = styled(Tabs)(({ theme }) => ({
borderColor: theme.palette.divider,
}));
export const TabsMenu = forwardRef(
({ children, sx, ...props }: TabsProps, ref: ForwardedRef<HTMLDivElement | null>) => {
const { appBarHeight } = useNavBarContext();
export const TabsMenu = ({ children, sx, ...props }: TabsProps) => {
const { appBarHeight } = useNavBarContext();
return (
<StyledTabsMenu
sx={{ ...sx, top: appBarHeight }}
ref={ref}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
{...props}
>
{children}
</StyledTabsMenu>
);
},
);
return (
<StyledTabsMenu
sx={{ ...sx, top: appBarHeight }}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
{...props}
>
{children}
</StyledTabsMenu>
);
};

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { ComponentType, memo, forwardRef } from 'react';
import { ComponentType, memo, Ref } from 'react';
type PropsSourceCreator<T, Props extends Record<string, any>> = (props: Props) => T;
@@ -21,19 +21,20 @@ export const withPropsFrom = <
},
sourcePropKeys: SourcePropKeys[],
) =>
memo(
forwardRef<HTMLElement, Omit<ComponentProps, SourcePropKeys>>((props, ref) => {
const sourceProps = propsSources.reduce(
(acc, propsSource) => ({ ...acc, ...propsSource(props as Omit<ComponentProps, SourcePropKeys>) }),
{},
);
memo(({ ref, ...props }: Omit<ComponentProps, SourcePropKeys> & { ref?: Ref<HTMLElement> }) => {
const sourceProps = propsSources.reduce(
(acc, propsSource) => ({
...acc,
...propsSource(props as unknown as Omit<ComponentProps, SourcePropKeys>),
}),
{},
);
const selectedProps = Object.fromEntries(
Object.entries(sourceProps).filter(([key]) => sourcePropKeys.includes(key as SourcePropKeys)),
) as Pick<MergeObjectsArray<SourceProps>, SourcePropKeys>;
const selectedProps = Object.fromEntries(
Object.entries(sourceProps).filter(([key]) => sourcePropKeys.includes(key as SourcePropKeys)),
) as Pick<MergeObjectsArray<SourceProps>, SourcePropKeys>;
const combinedProps = { ...props, ...selectedProps } as unknown as ComponentProps;
const combinedProps = { ...props, ...selectedProps } as unknown as ComponentProps;
return <Component {...combinedProps} ref={ref} />;
}),
);
return <Component {...combinedProps} ref={ref} />;
});