Skip to content

Commit c759a88

Browse files
authored
refactor(voice): load STT availability through React Query (#6224)
* refactor(voice): load STT availability through React Query 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. * test(queries): unmount rendered roots between tests 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. * fix(voice): let a failed STT probe recover on a later mount 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.
1 parent 3f70096 commit c759a88

4 files changed

Lines changed: 216 additions & 24 deletions

File tree

apps/sim/hooks/queries/chats.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { act, type ReactNode } from 'react'
55
import { sleep } from '@sim/utils/helpers'
66
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
77
import { createRoot, type Root } from 'react-dom/client'
8-
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99

1010
const { mockRequestJson, mockInvalidateDeploymentQueries } = vi.hoisted(() => ({
1111
mockRequestJson: vi.fn(),
@@ -23,11 +23,15 @@ vi.mock('@/hooks/queries/deployments', async (importOriginal) => ({
2323

2424
import { useCreateChat, useUpdateChat } from '@/hooks/queries/chats'
2525

26+
/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */
27+
const mountedRoots: Root[] = []
28+
2629
function renderHookWithClient<T>(useHook: () => T): { getResult: () => T } {
2730
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
2831
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
2932
const container = document.createElement('div')
3033
const root: Root = createRoot(container)
34+
mountedRoots.push(root)
3135
let result: T | undefined
3236

3337
function Probe() {
@@ -71,6 +75,12 @@ const FORM_DATA = {
7175
includeToolCalls: false,
7276
}
7377

78+
afterEach(() => {
79+
act(() => {
80+
for (const root of mountedRoots.splice(0)) root.unmount()
81+
})
82+
})
83+
7484
beforeEach(() => {
7585
vi.clearAllMocks()
7686
mockRequestJson.mockResolvedValue({ chatUrl: 'https://sim.ai/chat/my-chat', chatId: 'chat-1' })
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { sleep } from '@sim/utils/helpers'
6+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7+
import { createRoot, type Root } from 'react-dom/client'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() }))
11+
12+
vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson }))
13+
14+
import { useVoiceSettings, voiceSettingsKeys } from '@/hooks/queries/voice'
15+
16+
/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */
17+
const mountedRoots: Root[] = []
18+
19+
function renderHookWithClient<T>(
20+
useHook: () => T,
21+
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
22+
): { getResult: () => T } {
23+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
24+
const container = document.createElement('div')
25+
const root: Root = createRoot(container)
26+
mountedRoots.push(root)
27+
let result: T | undefined
28+
29+
function Probe() {
30+
result = useHook()
31+
return null
32+
}
33+
34+
act(() => {
35+
root.render(
36+
<QueryClientProvider client={queryClient}>{(<Probe />) as ReactNode}</QueryClientProvider>
37+
)
38+
})
39+
40+
return {
41+
getResult: () => {
42+
if (result === undefined) throw new Error('Hook result is not ready')
43+
return result
44+
},
45+
}
46+
}
47+
48+
async function flush() {
49+
await act(async () => {
50+
for (let i = 0; i < 5; i++) {
51+
await Promise.resolve()
52+
await sleep(1)
53+
}
54+
})
55+
}
56+
57+
afterEach(() => {
58+
act(() => {
59+
for (const root of mountedRoots.splice(0)) root.unmount()
60+
})
61+
})
62+
63+
beforeEach(() => {
64+
vi.clearAllMocks()
65+
})
66+
67+
describe('useVoiceSettings', () => {
68+
it('keys the query under the voiceSettings namespace', () => {
69+
expect(voiceSettingsKeys.settings()).toEqual(['voiceSettings', 'settings'])
70+
})
71+
72+
it('reports availability from the server response', async () => {
73+
mockRequestJson.mockResolvedValue({ sttAvailable: true })
74+
75+
const { getResult } = renderHookWithClient(() => useVoiceSettings())
76+
await flush()
77+
78+
expect(getResult().data).toBe(true)
79+
expect(mockRequestJson).toHaveBeenCalledTimes(1)
80+
})
81+
82+
/**
83+
* Consumers gate on a browser capability; a client that cannot stream audio
84+
* should never issue the request at all.
85+
*/
86+
it('issues no request when disabled', async () => {
87+
mockRequestJson.mockResolvedValue({ sttAvailable: true })
88+
89+
const { getResult } = renderHookWithClient(() => useVoiceSettings({ enabled: false }))
90+
await flush()
91+
92+
expect(mockRequestJson).not.toHaveBeenCalled()
93+
expect(getResult().data).toBeUndefined()
94+
})
95+
96+
/** A failed capability probe must read as unavailable, not throw. */
97+
it('leaves data undefined when the request fails', async () => {
98+
mockRequestJson.mockRejectedValue(new Error('offline'))
99+
100+
const { getResult } = renderHookWithClient(() => useVoiceSettings())
101+
await flush()
102+
103+
expect(getResult().data).toBeUndefined()
104+
expect(getResult().isError).toBe(true)
105+
})
106+
107+
/**
108+
* The app's QueryClient sets `retryOnMount: false`, and an infinite staleTime
109+
* never goes stale, so without an explicit override a single transient
110+
* failure would cache the error for the life of the client and keep the mic
111+
* hidden until a full reload.
112+
*/
113+
it('recovers on a later mount after a failed probe, under the app query defaults', async () => {
114+
const appDefaults = new QueryClient({
115+
defaultOptions: { queries: { retry: false, retryOnMount: false, staleTime: 30 * 1000 } },
116+
})
117+
118+
mockRequestJson.mockRejectedValueOnce(new Error('offline'))
119+
renderHookWithClient(() => useVoiceSettings(), appDefaults)
120+
await flush()
121+
expect(mockRequestJson).toHaveBeenCalledTimes(1)
122+
123+
mockRequestJson.mockResolvedValue({ sttAvailable: true })
124+
const second = renderHookWithClient(() => useVoiceSettings(), appDefaults)
125+
await flush()
126+
127+
expect(mockRequestJson).toHaveBeenCalledTimes(2)
128+
expect(second.getResult().data).toBe(true)
129+
})
130+
131+
it('dedupes across simultaneous consumers', async () => {
132+
mockRequestJson.mockResolvedValue({ sttAvailable: true })
133+
134+
renderHookWithClient(() => {
135+
useVoiceSettings()
136+
useVoiceSettings()
137+
return null
138+
})
139+
await flush()
140+
141+
expect(mockRequestJson).toHaveBeenCalledTimes(1)
142+
})
143+
})

apps/sim/hooks/queries/voice.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import { requestJson } from '@/lib/api/client/request'
3+
import { getVoiceSettingsContract } from '@/lib/api/contracts'
4+
5+
/**
6+
* Query key factory for voice capability queries
7+
*/
8+
export const voiceSettingsKeys = {
9+
all: ['voiceSettings'] as const,
10+
settings: () => [...voiceSettingsKeys.all, 'settings'] as const,
11+
}
12+
13+
/**
14+
* `/api/settings/voice` reports whether the server has an STT provider
15+
* configured, which is read from env at request time and so cannot change
16+
* within a session.
17+
*/
18+
export const VOICE_SETTINGS_STALE_TIME = Number.POSITIVE_INFINITY
19+
20+
async function fetchSttAvailable(signal?: AbortSignal): Promise<boolean> {
21+
const data = await requestJson(getVoiceSettingsContract, { signal })
22+
return data.sttAvailable === true
23+
}
24+
25+
/**
26+
* Loads whether server-side speech-to-text is configured.
27+
*
28+
* `enabled` is caller-controlled so consumers gated on a browser capability
29+
* skip the request entirely on clients that could not use STT anyway.
30+
*
31+
* Deliberately no `initialData`: consumers derive their support flag from
32+
* `data === true`, so the first client render matches the server render
33+
* (unavailable) until the fetch resolves.
34+
*
35+
* `retryOnMount` overrides the app default of `false`. An infinite staleTime
36+
* never goes stale, and `refetchOnWindowFocus` only refetches stale queries, so
37+
* without this a single transient failure would cache the error for the life of
38+
* the QueryClient and hide the mic until a full reload. Retrying per mount
39+
* matches the effect this replaced, which refetched every time it ran.
40+
*/
41+
export function useVoiceSettings(options?: { enabled?: boolean }) {
42+
return useQuery({
43+
queryKey: voiceSettingsKeys.settings(),
44+
queryFn: ({ signal }) => fetchSttAvailable(signal),
45+
enabled: options?.enabled ?? true,
46+
staleTime: VOICE_SETTINGS_STALE_TIME,
47+
retryOnMount: true,
48+
})
49+
}

apps/sim/hooks/use-speech-to-text.ts

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { isApiClientError } from '@/lib/api/client/errors'
66
import { requestJson } from '@/lib/api/client/request'
7-
import { getVoiceSettingsContract } from '@/lib/api/contracts/common'
87
import { speechTokenContract } from '@/lib/api/contracts/media/speech'
98
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
109
import {
@@ -13,6 +12,7 @@ import {
1312
MAX_SESSION_MS,
1413
SAMPLE_RATE,
1514
} from '@/lib/speech/config'
15+
import { useVoiceSettings } from '@/hooks/queries/voice'
1616

1717
const logger = createLogger('useSpeechToText')
1818

@@ -40,7 +40,18 @@ export function useSpeechToText({
4040
workspaceId,
4141
}: UseSpeechToTextProps): UseSpeechToTextReturn {
4242
const [isListening, setIsListening] = useState(false)
43-
const [isSupported, setIsSupported] = useState(false)
43+
/**
44+
* Gate the capability request on the browser APIs streaming needs, so clients
45+
* that could not use STT anyway never issue it.
46+
*/
47+
const browserSupportsAudioCapture =
48+
typeof window !== 'undefined' &&
49+
typeof AudioContext !== 'undefined' &&
50+
typeof WebSocket !== 'undefined' &&
51+
typeof navigator?.mediaDevices?.getUserMedia === 'function'
52+
53+
const { data: sttAvailable } = useVoiceSettings({ enabled: browserSupportsAudioCapture })
54+
const isSupported = browserSupportsAudioCapture && sttAvailable === true
4455

4556
const onTranscriptRef = useRef(onTranscript)
4657
const onUsageLimitExceededRef = useRef(onUsageLimitExceeded)
@@ -64,27 +75,6 @@ export function useSpeechToText({
6475
onUsageLimitExceededRef.current = onUsageLimitExceeded
6576
workspaceIdRef.current = workspaceId
6677

67-
useEffect(() => {
68-
const browserOk =
69-
typeof window !== 'undefined' &&
70-
typeof AudioContext !== 'undefined' &&
71-
typeof WebSocket !== 'undefined' &&
72-
typeof navigator?.mediaDevices?.getUserMedia === 'function'
73-
74-
if (!browserOk) {
75-
setIsSupported(false)
76-
return
77-
}
78-
79-
requestJson(getVoiceSettingsContract, {})
80-
.then((data) => {
81-
if (mountedRef.current) setIsSupported(data.sttAvailable === true)
82-
})
83-
.catch(() => {
84-
if (mountedRef.current) setIsSupported(false)
85-
})
86-
}, [])
87-
8878
const flushAudioBuffer = useCallback(() => {
8979
const ws = wsRef.current
9080
if (!ws || ws.readyState !== WebSocket.OPEN) return

0 commit comments

Comments
 (0)