diff --git a/src/renderer/src/components/settings/VoicePane.test.tsx b/src/renderer/src/components/settings/VoicePane.test.tsx index 2777d05cd..890d97165 100644 --- a/src/renderer/src/components/settings/VoicePane.test.tsx +++ b/src/renderer/src/components/settings/VoicePane.test.tsx @@ -4,6 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types' +import type { SpeechModelManifest } from '../../../../shared/speech-types' import type { GlobalSettings } from '../../../../shared/types' import { getDefaultVoiceSettings } from '../../../../shared/constants' import { handleVoiceDictationToggle, VoicePane } from './VoicePane' @@ -32,8 +33,12 @@ const deniedMicrophoneResult: DeveloperPermissionRequestResult = { status: 'denied', openedSystemSettings: false } +const EMPTY_SPEECH_CATALOG: SpeechModelManifest[] = [] -function makeSettings(voiceEnabled: boolean): GlobalSettings { +function makeSettings(voiceEnabled?: boolean): GlobalSettings { + if (voiceEnabled === undefined) { + return {} as GlobalSettings + } return { voice: { ...getDefaultVoiceSettings(), @@ -51,7 +56,7 @@ function installWindowApi( request: vi.fn(requestMicrophonePermission) }, speech: { - getCatalog: vi.fn(async () => []), + getCatalog: vi.fn(async () => EMPTY_SPEECH_CATALOG), getOpenAiApiKeyStatus: vi.fn(async () => ({ configured: false })), saveOpenAiApiKey: vi.fn(async () => ({ configured: true })), clearOpenAiApiKey: vi.fn(async () => ({ configured: false })), @@ -63,12 +68,17 @@ function installWindowApi( } async function renderVoicePane(args: { - voiceEnabled: boolean + voiceEnabled?: boolean markFeatureTipsSeen: (ids: string[]) => void updateSettings: (updates: Partial) => void requestMicrophonePermission?: () => Promise recordFeatureInteraction?: (id: string) => void -}): Promise<{ button: HTMLButtonElement; root: Root; container: HTMLDivElement }> { +}): Promise<{ + button: HTMLButtonElement + root: Root + container: HTMLDivElement + refreshModelStates: ReturnType +}> { const refreshModelStates = vi.fn() useAppStoreMock.mockImplementation((selector: (state: Record) => unknown) => selector({ @@ -95,7 +105,7 @@ async function renderVoicePane(args: { throw new Error('Voice Dictation switch was not rendered') } - return { button, root, container } + return { button, root, container, refreshModelStates } } async function clickSwitch(button: HTMLButtonElement): Promise { @@ -107,7 +117,7 @@ async function clickSwitch(button: HTMLButtonElement): Promise { }) } -describe('VoicePane dictation switch', () => { +describe('VoicePane', () => { afterEach(() => { vi.unstubAllGlobals() document.body.innerHTML = '' @@ -118,6 +128,24 @@ describe('VoicePane dictation switch', () => { useShortcutLabelMock.mockReset() }) + it('fetches speech data once across re-renders when voice settings are absent', async () => { + const updateSettings = vi.fn() + const { root, refreshModelStates } = await renderVoicePane({ + markFeatureTipsSeen: vi.fn(), + updateSettings + }) + + for (let i = 0; i < 4; i++) { + await act(async () => { + root.render() + }) + } + act(() => root.unmount()) + + expect(window.api.speech.getCatalog).toHaveBeenCalledTimes(1) + expect(refreshModelStates).toHaveBeenCalledTimes(1) + }) + it('clicking the switch marks the voice tip seen before disabling voice settings', async () => { const calls: string[] = [] const requestMicrophonePermission = vi.fn() diff --git a/src/renderer/src/components/settings/VoicePane.tsx b/src/renderer/src/components/settings/VoicePane.tsx index 9d9cb5c9e..a1fdfbe9e 100644 --- a/src/renderer/src/components/settings/VoicePane.tsx +++ b/src/renderer/src/components/settings/VoicePane.tsx @@ -22,7 +22,9 @@ type VoicePaneProps = { } export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.JSX.Element { - const voiceSettings = settings.voice ?? getDefaultVoiceSettings() + // Why: a stable fallback prevents the fetch effect from repeating on every parent render. + const [defaultVoiceSettings] = useState(getDefaultVoiceSettings) + const voiceSettings = settings.voice ?? defaultVoiceSettings const modelStates = useAppStore((s) => s.modelStates) const refreshModelStates = useAppStore((s) => s.refreshModelStates) const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen)