From 4eda6c0eb2ea8d8e88cd6d894cd6e47410a7cc4d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 12:53:20 -0700 Subject: [PATCH 1/3] refactor(voice): load STT availability through React Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSpeechToText fetched `/api/settings/voice` inside an effect and stored the result in useState behind a hand-rolled mountedRef guard: no cache, no dedupe across mounts, and no AbortSignal, so the response was fetched and parsed even after unmount. Two simultaneously mounted consumers issued two requests. It also bypassed hooks/queries/**, which is where every other server read in the app lives — and it escaped `check:react-query`, whose audit only covers useQuery/useMutation call sites. The value is server env read at request time, so it cannot change within a session; the new hook uses an infinite staleTime and a caller-controlled `enabled` so clients without the audio APIs never issue the request. Hydration is unchanged: SSR renders unavailable, and the first client render still resolves unavailable because `data` is undefined until the fetch settles. No initialData, deliberately — adding it would break that. mountedRef stays; it is still load-bearing for the streaming lifecycle. --- apps/sim/hooks/queries/voice.test.tsx | 107 ++++++++++++++++++++++++++ apps/sim/hooks/queries/voice.ts | 42 ++++++++++ apps/sim/hooks/use-speech-to-text.ts | 36 ++++----- 3 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 apps/sim/hooks/queries/voice.test.tsx create mode 100644 apps/sim/hooks/queries/voice.ts diff --git a/apps/sim/hooks/queries/voice.test.tsx b/apps/sim/hooks/queries/voice.test.tsx new file mode 100644 index 00000000000..8bfb05173c8 --- /dev/null +++ b/apps/sim/hooks/queries/voice.test.tsx @@ -0,0 +1,107 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) + +import { useVoiceSettings, voiceSettingsKeys } from '@/hooks/queries/voice' + +function renderHookWithClient(useHook: () => T): { getResult: () => T } { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root: Root = createRoot(container) + let result: T | undefined + + function Probe() { + result = useHook() + return null + } + + act(() => { + root.render( + {() as ReactNode} + ) + }) + + return { + getResult: () => { + if (result === undefined) throw new Error('Hook result is not ready') + return result + }, + } +} + +async function flush() { + await act(async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve() + await sleep(1) + } + }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('useVoiceSettings', () => { + it('keys the query under the voiceSettings namespace', () => { + expect(voiceSettingsKeys.settings()).toEqual(['voiceSettings', 'settings']) + }) + + it('reports availability from the server response', async () => { + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + + const { getResult } = renderHookWithClient(() => useVoiceSettings()) + await flush() + + expect(getResult().data).toBe(true) + expect(mockRequestJson).toHaveBeenCalledTimes(1) + }) + + /** + * Consumers gate on a browser capability; a client that cannot stream audio + * should never issue the request at all. + */ + it('issues no request when disabled', async () => { + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + + const { getResult } = renderHookWithClient(() => useVoiceSettings({ enabled: false })) + await flush() + + expect(mockRequestJson).not.toHaveBeenCalled() + expect(getResult().data).toBeUndefined() + }) + + /** A failed capability probe must read as unavailable, not throw. */ + it('leaves data undefined when the request fails', async () => { + mockRequestJson.mockRejectedValue(new Error('offline')) + + const { getResult } = renderHookWithClient(() => useVoiceSettings()) + await flush() + + expect(getResult().data).toBeUndefined() + expect(getResult().isError).toBe(true) + }) + + it('dedupes across simultaneous consumers', async () => { + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + + renderHookWithClient(() => { + useVoiceSettings() + useVoiceSettings() + return null + }) + await flush() + + expect(mockRequestJson).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/hooks/queries/voice.ts b/apps/sim/hooks/queries/voice.ts new file mode 100644 index 00000000000..40d486e9cf4 --- /dev/null +++ b/apps/sim/hooks/queries/voice.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getVoiceSettingsContract } from '@/lib/api/contracts' + +/** + * Query key factory for voice capability queries + */ +export const voiceSettingsKeys = { + all: ['voiceSettings'] as const, + settings: () => [...voiceSettingsKeys.all, 'settings'] as const, +} + +/** + * `/api/settings/voice` reports whether the server has an STT provider + * configured, which is read from env at request time and so cannot change + * within a session. + */ +export const VOICE_SETTINGS_STALE_TIME = Number.POSITIVE_INFINITY + +async function fetchSttAvailable(signal?: AbortSignal): Promise { + const data = await requestJson(getVoiceSettingsContract, { signal }) + return data.sttAvailable === true +} + +/** + * Loads whether server-side speech-to-text is configured. + * + * `enabled` is caller-controlled so consumers gated on a browser capability + * skip the request entirely on clients that could not use STT anyway. + * + * Deliberately no `initialData`: consumers derive their support flag from + * `data === true`, so the first client render matches the server render + * (unavailable) until the fetch resolves. + */ +export function useVoiceSettings(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: voiceSettingsKeys.settings(), + queryFn: ({ signal }) => fetchSttAvailable(signal), + enabled: options?.enabled ?? true, + staleTime: VOICE_SETTINGS_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/use-speech-to-text.ts b/apps/sim/hooks/use-speech-to-text.ts index f6d7cd23893..6a1bcb2f726 100644 --- a/apps/sim/hooks/use-speech-to-text.ts +++ b/apps/sim/hooks/use-speech-to-text.ts @@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' -import { getVoiceSettingsContract } from '@/lib/api/contracts/common' import { speechTokenContract } from '@/lib/api/contracts/media/speech' import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio' import { @@ -13,6 +12,7 @@ import { MAX_SESSION_MS, SAMPLE_RATE, } from '@/lib/speech/config' +import { useVoiceSettings } from '@/hooks/queries/voice' const logger = createLogger('useSpeechToText') @@ -40,7 +40,18 @@ export function useSpeechToText({ workspaceId, }: UseSpeechToTextProps): UseSpeechToTextReturn { const [isListening, setIsListening] = useState(false) - const [isSupported, setIsSupported] = useState(false) + /** + * Gate the capability request on the browser APIs streaming needs, so clients + * that could not use STT anyway never issue it. + */ + const browserSupportsAudioCapture = + typeof window !== 'undefined' && + typeof AudioContext !== 'undefined' && + typeof WebSocket !== 'undefined' && + typeof navigator?.mediaDevices?.getUserMedia === 'function' + + const { data: sttAvailable } = useVoiceSettings({ enabled: browserSupportsAudioCapture }) + const isSupported = browserSupportsAudioCapture && sttAvailable === true const onTranscriptRef = useRef(onTranscript) const onUsageLimitExceededRef = useRef(onUsageLimitExceeded) @@ -64,27 +75,6 @@ export function useSpeechToText({ onUsageLimitExceededRef.current = onUsageLimitExceeded workspaceIdRef.current = workspaceId - useEffect(() => { - const browserOk = - typeof window !== 'undefined' && - typeof AudioContext !== 'undefined' && - typeof WebSocket !== 'undefined' && - typeof navigator?.mediaDevices?.getUserMedia === 'function' - - if (!browserOk) { - setIsSupported(false) - return - } - - requestJson(getVoiceSettingsContract, {}) - .then((data) => { - if (mountedRef.current) setIsSupported(data.sttAvailable === true) - }) - .catch(() => { - if (mountedRef.current) setIsSupported(false) - }) - }, []) - const flushAudioBuffer = useCallback(() => { const ws = wsRef.current if (!ws || ws.readyState !== WebSocket.OPEN) return From 7af7b839aa71c97e22c93879ac68893a956689d7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 13:00:37 -0700 Subject: [PATCH 2/3] test(queries): unmount rendered roots between tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderHookWithClient created a React root per test but never tore it down, so trees stayed mounted with live QueryClient observers until worker teardown and async notifications could cross test boundaries. Audited every test in the repo using createRoot: 51 of 53 already unmount. The two that did not were both mine — voice.test.tsx here and chats.test.tsx from #6223 — so both are fixed and the pattern is now uniform. --- apps/sim/hooks/queries/chats.test.tsx | 12 +++++++++++- apps/sim/hooks/queries/voice.test.tsx | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/sim/hooks/queries/chats.test.tsx b/apps/sim/hooks/queries/chats.test.tsx index 03cdf31dd18..c485fc1aa6f 100644 --- a/apps/sim/hooks/queries/chats.test.tsx +++ b/apps/sim/hooks/queries/chats.test.tsx @@ -5,7 +5,7 @@ import { act, type ReactNode } from 'react' import { sleep } from '@sim/utils/helpers' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRequestJson, mockInvalidateDeploymentQueries } = vi.hoisted(() => ({ mockRequestJson: vi.fn(), @@ -23,11 +23,15 @@ vi.mock('@/hooks/queries/deployments', async (importOriginal) => ({ import { useCreateChat, useUpdateChat } from '@/hooks/queries/chats' +/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */ +const mountedRoots: Root[] = [] + function renderHookWithClient(useHook: () => T): { getResult: () => T } { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const container = document.createElement('div') const root: Root = createRoot(container) + mountedRoots.push(root) let result: T | undefined function Probe() { @@ -71,6 +75,12 @@ const FORM_DATA = { includeToolCalls: false, } +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) +}) + beforeEach(() => { vi.clearAllMocks() mockRequestJson.mockResolvedValue({ chatUrl: 'https://sim.ai/chat/my-chat', chatId: 'chat-1' }) diff --git a/apps/sim/hooks/queries/voice.test.tsx b/apps/sim/hooks/queries/voice.test.tsx index 8bfb05173c8..22e2a1a9850 100644 --- a/apps/sim/hooks/queries/voice.test.tsx +++ b/apps/sim/hooks/queries/voice.test.tsx @@ -5,7 +5,7 @@ import { act, type ReactNode } from 'react' import { sleep } from '@sim/utils/helpers' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) @@ -13,11 +13,15 @@ vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) import { useVoiceSettings, voiceSettingsKeys } from '@/hooks/queries/voice' +/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */ +const mountedRoots: Root[] = [] + function renderHookWithClient(useHook: () => T): { getResult: () => T } { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const container = document.createElement('div') const root: Root = createRoot(container) + mountedRoots.push(root) let result: T | undefined function Probe() { @@ -48,6 +52,12 @@ async function flush() { }) } +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) +}) + beforeEach(() => { vi.clearAllMocks() }) From f06b8663cb88c76a0e08e8b3d12a6ae4be3186c1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 3 Aug 2026 13:12:26 -0700 Subject: [PATCH 3/3] fix(voice): let a failed STT probe recover on a later mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app QueryClient sets retryOnMount: false and retry: 1, and refetchOnWindowFocus only refetches stale queries — which an infinite staleTime never becomes. So one transient failure cached the error for the life of the client and hid the mic until a full page reload. The effect this replaced refetched on every run, so retryOnMount: true restores parity: no refetch after success, a retry per mount after failure. Test asserts recovery under the app's real query defaults and fails without the override. --- apps/sim/hooks/queries/voice.test.tsx | 30 +++++++++++++++++++++++++-- apps/sim/hooks/queries/voice.ts | 7 +++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/sim/hooks/queries/voice.test.tsx b/apps/sim/hooks/queries/voice.test.tsx index 22e2a1a9850..994963e812a 100644 --- a/apps/sim/hooks/queries/voice.test.tsx +++ b/apps/sim/hooks/queries/voice.test.tsx @@ -16,9 +16,11 @@ import { useVoiceSettings, voiceSettingsKeys } from '@/hooks/queries/voice' /** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */ const mountedRoots: Root[] = [] -function renderHookWithClient(useHook: () => T): { getResult: () => T } { +function renderHookWithClient( + useHook: () => T, + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +): { getResult: () => T } { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const container = document.createElement('div') const root: Root = createRoot(container) mountedRoots.push(root) @@ -102,6 +104,30 @@ describe('useVoiceSettings', () => { expect(getResult().isError).toBe(true) }) + /** + * The app's QueryClient sets `retryOnMount: false`, and an infinite staleTime + * never goes stale, so without an explicit override a single transient + * failure would cache the error for the life of the client and keep the mic + * hidden until a full reload. + */ + it('recovers on a later mount after a failed probe, under the app query defaults', async () => { + const appDefaults = new QueryClient({ + defaultOptions: { queries: { retry: false, retryOnMount: false, staleTime: 30 * 1000 } }, + }) + + mockRequestJson.mockRejectedValueOnce(new Error('offline')) + renderHookWithClient(() => useVoiceSettings(), appDefaults) + await flush() + expect(mockRequestJson).toHaveBeenCalledTimes(1) + + mockRequestJson.mockResolvedValue({ sttAvailable: true }) + const second = renderHookWithClient(() => useVoiceSettings(), appDefaults) + await flush() + + expect(mockRequestJson).toHaveBeenCalledTimes(2) + expect(second.getResult().data).toBe(true) + }) + it('dedupes across simultaneous consumers', async () => { mockRequestJson.mockResolvedValue({ sttAvailable: true }) diff --git a/apps/sim/hooks/queries/voice.ts b/apps/sim/hooks/queries/voice.ts index 40d486e9cf4..065821344f3 100644 --- a/apps/sim/hooks/queries/voice.ts +++ b/apps/sim/hooks/queries/voice.ts @@ -31,6 +31,12 @@ async function fetchSttAvailable(signal?: AbortSignal): Promise { * Deliberately no `initialData`: consumers derive their support flag from * `data === true`, so the first client render matches the server render * (unavailable) until the fetch resolves. + * + * `retryOnMount` overrides the app default of `false`. An infinite staleTime + * never goes stale, and `refetchOnWindowFocus` only refetches stale queries, so + * without this a single transient failure would cache the error for the life of + * the QueryClient and hide the mic until a full reload. Retrying per mount + * matches the effect this replaced, which refetched every time it ran. */ export function useVoiceSettings(options?: { enabled?: boolean }) { return useQuery({ @@ -38,5 +44,6 @@ export function useVoiceSettings(options?: { enabled?: boolean }) { queryFn: ({ signal }) => fetchSttAvailable(signal), enabled: options?.enabled ?? true, staleTime: VOICE_SETTINGS_STALE_TIME, + retryOnMount: true, }) }