From f7c93c90a6fd59c565916690871bf7a5086dc64c Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 13:07:51 -0600 Subject: [PATCH] fix(player): prevent selection reset during voice refetch by deriving voices from props Remove internal selectedVoices state and use a memoized value based on the voice prop to determine the selected voices. Avoid side effects on selection display, ensuring that voice selection persists even when available voices are refetched. This addresses issues where transient backend states could cause unwanted resets to the first voice option. --- src/components/player/VoicesControlBase.tsx | 25 ++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx index b314902..f2e4a3e 100644 --- a/src/components/player/VoicesControlBase.tsx +++ b/src/components/player/VoicesControlBase.tsx @@ -4,7 +4,7 @@ import { Listbox, } from '@headlessui/react'; import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; import { keepKokoroVoicesInOneLanguage } from '@/lib/shared/language'; import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; @@ -56,10 +56,15 @@ export function VoicesControlBase({ const isKokoro = providerModelPolicy.isKokoroModel; const maxVoices = providerModelPolicy.maxVoices; - const [selectedVoices, setSelectedVoices] = useState([]); - - useEffect(() => { - if (!(isKokoro && maxVoices > 1)) return; + // Selected voices are derived from the `voice` prop for display only. The + // control is fully controlled: it never writes back to config as a side + // effect, only in direct response to a user selection (see onChange below). + // A write-back effect here would re-fire during the availableVoices refetch + // churn that a voice change triggers and, under a slow/contended backend, + // push a transiently-collapsed selection back as the voice — resetting it to + // the first option. + const selectedVoices = useMemo(() => { + if (!(isKokoro && maxVoices > 1)) return []; let initial: string[] = []; if (voice && voice.includes('+')) { initial = parseKokoroVoiceNames(voice); @@ -71,13 +76,8 @@ export function VoicesControlBase({ if (initial.length > maxVoices) { initial = initial.slice(0, maxVoices); } - initial = keepKokoroVoicesInOneLanguage(initial); - setSelectedVoices(initial); - const combined = buildKokoroVoiceString(initial); - if (combined && combined !== voice) { - onChangeVoice(combined); - } - }, [isKokoro, maxVoices, voice, availableVoices, onChangeVoice]); + return keepKokoroVoicesInOneLanguage(initial); + }, [isKokoro, maxVoices, voice, availableVoices]); const currentVoice = useMemo(() => { if (isKokoro && maxVoices > 1) { @@ -123,7 +123,6 @@ export function VoicesControlBase({ } } - setSelectedVoices(next); const combined = buildKokoroVoiceString(next); if (combined) { onChangeVoice(combined);