Fix infinite hook call loop

Regression a7dcdd5651

"usePrevious" uses "useEffect" to update the ref while the fix of the commit updated the state during the render function call.
This causes the component to not actually render and immediately call the render function again. Which then leads to the "usePrevious" hooks "useEffect" to never getting called.
This commit is contained in:
schroda
2026-05-05 02:09:36 +02:00
parent 4f7fb68e42
commit 1ee454c137
3 changed files with 208 additions and 161 deletions

View File

@@ -40,16 +40,21 @@ import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx
import { makeToast } from '@/base/utils/Toast.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
import { usePrevious } from '@mantine/hooks';
import { AwaitableComponent, type AwaitableComponentProps } from 'awaitable-component';
export const SourceLanguageSelect = ({
const SourceLanguageSelectDialog = ({
isVisible,
onDismiss,
onSubmit,
onExitComplete,
selectedLanguages,
setSelectedLanguages,
languages,
sources,
}: {
}: AwaitableComponentProps<{
selectedLanguages: string[];
sourceEnabledStateMetaUpdatePayload: Parameters<typeof batchUpdateSourceMetadata>[0];
}> & {
selectedLanguages: string[];
setSelectedLanguages: (languages: string[]) => void;
languages: string[];
sources: (SourceIdInfo &
SourceLanguageInfo &
@@ -63,12 +68,6 @@ export const SourceLanguageSelect = ({
const [tmpSourceIdToEnabledState, setTmpSourceIdToEnabledState] = useState<Record<SourceIdInfo['id'], boolean>>({});
const [tmpSelectedLanguages, setTmpSelectedLanguages] = useState(toUniqueLanguageCodes(selectedLanguages));
const [open, setOpen] = useState<boolean>(false);
const previousSelectedLanguages = usePrevious(selectedLanguages);
if (previousSelectedLanguages && previousSelectedLanguages !== selectedLanguages) {
setTmpSelectedLanguages(toUniqueLanguageCodes(selectedLanguages));
}
const sourcesByLanguage = useMemo(() => Sources.groupByLanguage(sources), [sources]);
@@ -109,18 +108,10 @@ export const SourceLanguageSelect = ({
useCallback((index) => flattenedSourcesByLanguages[index].id, [flattenedSourcesByLanguages]),
);
const handleCancel = () => {
setOpen(false);
setTmpSourceIdToEnabledState({});
setTmpSelectedLanguages(toUniqueLanguageCodes(selectedLanguages));
};
const handleOk = () => {
setOpen(false);
batchUpdateSourceMetadata(
Object.entries(tmpSourceIdToEnabledState)
onSubmit({
selectedLanguages: toUniqueLanguageCodes(tmpSelectedLanguages),
sourceEnabledStateMetaUpdatePayload: Object.entries(tmpSourceIdToEnabledState)
.map(([sourceId, enabled]) => {
const source = sources.find((sourceToEnable) => sourceToEnable.id === sourceId);
@@ -134,9 +125,7 @@ export const SourceLanguageSelect = ({
};
})
.filter((entry) => entry !== null),
).catch((e) => makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)));
setTmpSourceIdToEnabledState({});
setSelectedLanguages(toUniqueLanguageCodes(tmpSelectedLanguages));
});
};
const handleChange = (language: string, selected: boolean) => {
@@ -148,92 +137,127 @@ export const SourceLanguageSelect = ({
};
return (
<>
<CustomTooltip title={t`Settings`}>
<IconButton onClick={() => setOpen(true)} aria-label="display more actions" edge="end" color="inherit">
<FilterListIcon />
</IconButton>
</CustomTooltip>
<Dialog fullWidth maxWidth="xs" open={open} onClose={handleCancel}>
<DialogTitle>{t`Allowed Languages`}</DialogTitle>
<DialogContent dividers sx={{ padding: 0 }}>
{!languages.length && <Box sx={{ p: 1 }}>{t`No sources installed`}</Box>}
<GroupedVirtuoso
style={{
height: languagesSortedBySelectState.length * 54,
minHeight: '25vh',
maxHeight: '50vh',
}}
groupCounts={groupCounts}
increaseViewportBy={400}
computeItemKey={computeItemKey}
groupContent={(index) => {
const language = languagesSortedBySelectState[index];
const isEnabled = tmpSelectedLanguages.includes(language);
<Dialog fullWidth maxWidth="xs" open={isVisible} onClose={onDismiss} onTransitionExited={onExitComplete}>
<DialogTitle>{t`Allowed Languages`}</DialogTitle>
<DialogContent dividers sx={{ padding: 0 }}>
{!languages.length && <Box sx={{ p: 1 }}>{t`No sources installed`}</Box>}
<GroupedVirtuoso
style={{
height: languagesSortedBySelectState.length * 54,
minHeight: '25vh',
maxHeight: '50vh',
}}
groupCounts={groupCounts}
increaseViewportBy={400}
computeItemKey={computeItemKey}
groupContent={(index) => {
const language = languagesSortedBySelectState[index];
const isEnabled = tmpSelectedLanguages.includes(language);
return (
<ListItem
sx={{
backgroundColor: 'background.paper',
backgroundImage: 'var(--Paper-overlay)',
}}
>
<ListItemText primary={translateExtensionLanguage(language)} />
<Switch
checked={isEnabled}
onChange={(e) => handleChange(language, e.target.checked)}
/>
</ListItem>
);
}}
itemContent={(index) => {
const source = flattenedSourcesByLanguages[index];
return (
<ListItem
sx={{
backgroundColor: 'background.paper',
backgroundImage: 'var(--Paper-overlay)',
}}
>
<ListItemText primary={translateExtensionLanguage(language)} />
<Switch
checked={isEnabled}
onChange={(e) => handleChange(language, e.target.checked)}
/>
</ListItem>
);
}}
itemContent={(index) => {
const source = flattenedSourcesByLanguages[index];
return (
<ListItem sx={{ pl: 3 }}>
<ListItemAvatar sx={{ minWidth: 32, mr: 1 }}>
<ListCardAvatar
iconUrl={requestManager.getValidImgUrlFor(source.iconUrl)}
alt={source.name}
slots={{
avatarProps: {
sx: {
width: 32,
height: 32,
},
return (
<ListItem sx={{ pl: 3 }}>
<ListItemAvatar sx={{ minWidth: 32, mr: 1 }}>
<ListCardAvatar
iconUrl={requestManager.getValidImgUrlFor(source.iconUrl)}
alt={source.name}
slots={{
avatarProps: {
sx: {
width: 32,
height: 32,
},
spinnerImageProps: {
ignoreQueue: true,
},
}}
/>
</ListItemAvatar>
<ListItemText primary={source.name} />
<Checkbox
checked={
tmpSourceIdToEnabledState[source.id] ?? getSourceMetadata(source).isEnabled
}
onChange={(e) =>
setTmpSourceIdToEnabledState({
...tmpSourceIdToEnabledState,
[source.id]: e.target.checked,
})
}
},
spinnerImageProps: {
ignoreQueue: true,
},
}}
/>
</ListItem>
);
}}
/>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel} color="primary">
{t`Cancel`}
</Button>
<Button onClick={handleOk} color="primary">
{t`Ok`}
</Button>
</DialogActions>
</Dialog>
</>
</ListItemAvatar>
<ListItemText primary={source.name} />
<Checkbox
checked={
tmpSourceIdToEnabledState[source.id] ?? getSourceMetadata(source).isEnabled
}
onChange={(e) =>
setTmpSourceIdToEnabledState({
...tmpSourceIdToEnabledState,
[source.id]: e.target.checked,
})
}
/>
</ListItem>
);
}}
/>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={onDismiss} color="primary">
{t`Cancel`}
</Button>
<Button onClick={handleOk} color="primary">
{t`Ok`}
</Button>
</DialogActions>
</Dialog>
);
};
export const SourceLanguageSelect = ({
setSelectedLanguages,
...props
}: {
selectedLanguages: string[];
setSelectedLanguages: (languages: string[]) => Promise<void>;
languages: string[];
sources: (SourceIdInfo &
SourceLanguageInfo &
SourceNameInfo &
SourceDisplayNameInfo &
SourceIconInfo &
SourceMetaInfo)[];
}) => {
const { t } = useLingui();
return (
<CustomTooltip title={t`Settings`}>
<IconButton
onClick={async () => {
try {
const { selectedLanguages: updatedSelectedLanguages, sourceEnabledStateMetaUpdatePayload } =
await AwaitableComponent.show(SourceLanguageSelectDialog, props);
await Promise.all([
setSelectedLanguages(updatedSelectedLanguages),
batchUpdateSourceMetadata(sourceEnabledStateMetaUpdatePayload),
]).catch((e) => makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)));
} catch (e) {
// Ignore
}
}}
aria-label="display more actions"
edge="end"
color="inherit"
>
<FilterListIcon />
</IconButton>
</CustomTooltip>
);
};

View File

@@ -176,7 +176,7 @@ export class Sources {
static useLanguages(): {
languages: string[];
setLanguages: (languages: string[]) => void;
setLanguages: (languages: string[]) => Promise<void>;
} {
const { t } = useLingui();
const {