From 1ee454c137625452d31775aaeb7f92c20fef82a1 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Tue, 5 May 2026 02:09:36 +0200 Subject: [PATCH] Fix infinite hook call loop Regression a7dcdd5651a140c3e3b295e6963211fca88d3b5d "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. --- src/base/components/inputs/LanguageSelect.tsx | 127 +++++---- .../components/SourceLanguageSelect.tsx | 240 ++++++++++-------- src/features/source/services/Sources.ts | 2 +- 3 files changed, 208 insertions(+), 161 deletions(-) diff --git a/src/base/components/inputs/LanguageSelect.tsx b/src/base/components/inputs/LanguageSelect.tsx index e542c140..815d7472 100644 --- a/src/base/components/inputs/LanguageSelect.tsx +++ b/src/base/components/inputs/LanguageSelect.tsx @@ -23,18 +23,22 @@ import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; import { translateExtensionLanguage } from '@/features/extension/Extensions.utils.ts'; import { languageSortComparator, toUniqueLanguageCodes } from '@/base/utils/Languages.ts'; import { usePrevious } from '@mantine/hooks'; +import { AwaitableComponent, type AwaitableComponentProps } from 'awaitable-component'; -interface IProps { +const LanguageSelectDialog = ({ + isVisible, + onDismiss, + onSubmit, + onExitComplete, + selectedLanguages, + languages, +}: AwaitableComponentProps & { selectedLanguages: string[]; - setSelectedLanguages: (languages: string[]) => void; languages: string[]; -} - -export function LanguageSelect({ selectedLanguages, setSelectedLanguages, languages }: IProps) { +}) => { const { t } = useLingui(); const [tmpSelectedLanguages, setTmpSelectedLanguages] = useState(toUniqueLanguageCodes(selectedLanguages)); - const [open, setOpen] = useState(false); const previousSelectedLanguages = usePrevious(selectedLanguages); @@ -53,14 +57,8 @@ export function LanguageSelect({ selectedLanguages, setSelectedLanguages, langua [languages, tmpSelectedLanguages], ); - const handleCancel = () => { - setOpen(false); - setTmpSelectedLanguages(toUniqueLanguageCodes(selectedLanguages)); - }; - const handleOk = () => { - setOpen(false); - setSelectedLanguages(toUniqueLanguageCodes(tmpSelectedLanguages)); + onSubmit(toUniqueLanguageCodes(tmpSelectedLanguages)); }; const handleChange = (language: string, selected: boolean) => { @@ -72,45 +70,70 @@ export function LanguageSelect({ selectedLanguages, setSelectedLanguages, langua }; return ( - <> - - setOpen(true)} aria-label="display more actions" edge="end" color="inherit"> - - - - - {t`Allowed Languages`} - - languagesSortedBySelectState[index]} - itemContent={(_index, language) => ( - - + + {t`Allowed Languages`} + + languagesSortedBySelectState[index]} + itemContent={(_index, language) => ( + + - handleChange(language, e.target.checked)} - /> - - )} - /> - - - - - - - + handleChange(language, e.target.checked)} + /> + + )} + /> + + + + + + + ); +}; + +export function LanguageSelect({ + setSelectedLanguages, + ...props +}: { + selectedLanguages: string[]; + setSelectedLanguages: (languages: string[]) => void; + languages: string[]; +}) { + const { t } = useLingui(); + + return ( + + { + try { + const updatedSelectedLanguages = await AwaitableComponent.show(LanguageSelectDialog, props); + + setSelectedLanguages(updatedSelectedLanguages); + } catch (e) { + // ignore + } + }} + aria-label="display more actions" + edge="end" + color="inherit" + > + + + ); } diff --git a/src/features/source/components/SourceLanguageSelect.tsx b/src/features/source/components/SourceLanguageSelect.tsx index 4e8702cd..0ceb7fab 100644 --- a/src/features/source/components/SourceLanguageSelect.tsx +++ b/src/features/source/components/SourceLanguageSelect.tsx @@ -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[0]; +}> & { selectedLanguages: string[]; - setSelectedLanguages: (languages: string[]) => void; languages: string[]; sources: (SourceIdInfo & SourceLanguageInfo & @@ -63,12 +68,6 @@ export const SourceLanguageSelect = ({ const [tmpSourceIdToEnabledState, setTmpSourceIdToEnabledState] = useState>({}); const [tmpSelectedLanguages, setTmpSelectedLanguages] = useState(toUniqueLanguageCodes(selectedLanguages)); - const [open, setOpen] = useState(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 ( - <> - - setOpen(true)} aria-label="display more actions" edge="end" color="inherit"> - - - - - {t`Allowed Languages`} - - {!languages.length && {t`No sources installed`}} - { - const language = languagesSortedBySelectState[index]; - const isEnabled = tmpSelectedLanguages.includes(language); + + {t`Allowed Languages`} + + {!languages.length && {t`No sources installed`}} + { + const language = languagesSortedBySelectState[index]; + const isEnabled = tmpSelectedLanguages.includes(language); - return ( - - - handleChange(language, e.target.checked)} - /> - - ); - }} - itemContent={(index) => { - const source = flattenedSourcesByLanguages[index]; + return ( + + + handleChange(language, e.target.checked)} + /> + + ); + }} + itemContent={(index) => { + const source = flattenedSourcesByLanguages[index]; - return ( - - - + + - - - - setTmpSourceIdToEnabledState({ - ...tmpSourceIdToEnabledState, - [source.id]: e.target.checked, - }) - } + }, + spinnerImageProps: { + ignoreQueue: true, + }, + }} /> - - ); - }} - /> - - - - - - - + + + + setTmpSourceIdToEnabledState({ + ...tmpSourceIdToEnabledState, + [source.id]: e.target.checked, + }) + } + /> + + ); + }} + /> + + + + + + + ); +}; + +export const SourceLanguageSelect = ({ + setSelectedLanguages, + ...props +}: { + selectedLanguages: string[]; + setSelectedLanguages: (languages: string[]) => Promise; + languages: string[]; + sources: (SourceIdInfo & + SourceLanguageInfo & + SourceNameInfo & + SourceDisplayNameInfo & + SourceIconInfo & + SourceMetaInfo)[]; +}) => { + const { t } = useLingui(); + + return ( + + { + 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" + > + + + ); }; diff --git a/src/features/source/services/Sources.ts b/src/features/source/services/Sources.ts index acff06c5..df0f3fbb 100644 --- a/src/features/source/services/Sources.ts +++ b/src/features/source/services/Sources.ts @@ -176,7 +176,7 @@ export class Sources { static useLanguages(): { languages: string[]; - setLanguages: (languages: string[]) => void; + setLanguages: (languages: string[]) => Promise; } { const { t } = useLingui(); const {