Skip to content

Commit 384ab77

Browse files
committed
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.
1 parent 83988c1 commit 384ab77

3 files changed

Lines changed: 163 additions & 23 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 { 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+
function renderHookWithClient<T>(useHook: () => T): { getResult: () => T } {
17+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
18+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
19+
const container = document.createElement('div')
20+
const root: Root = createRoot(container)
21+
let result: T | undefined
22+
23+
function Probe() {
24+
result = useHook()
25+
return null
26+
}
27+
28+
act(() => {
29+
root.render(
30+
<QueryClientProvider client={queryClient}>{(<Probe />) as ReactNode}</QueryClientProvider>
31+
)
32+
})
33+
34+
return {
35+
getResult: () => {
36+
if (result === undefined) throw new Error('Hook result is not ready')
37+
return result
38+
},
39+
}
40+
}
41+
42+
async function flush() {
43+
await act(async () => {
44+
for (let i = 0; i < 5; i++) {
45+
await Promise.resolve()
46+
await sleep(1)
47+
}
48+
})
49+
}
50+
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
})
54+
55+
describe('useVoiceSettings', () => {
56+
it('keys the query under the voiceSettings namespace', () => {
57+
expect(voiceSettingsKeys.settings()).toEqual(['voiceSettings', 'settings'])
58+
})
59+
60+
it('reports availability from the server response', async () => {
61+
mockRequestJson.mockResolvedValue({ sttAvailable: true })
62+
63+
const { getResult } = renderHookWithClient(() => useVoiceSettings())
64+
await flush()
65+
66+
expect(getResult().data).toBe(true)
67+
expect(mockRequestJson).toHaveBeenCalledTimes(1)
68+
})
69+
70+
/**
71+
* Consumers gate on a browser capability; a client that cannot stream audio
72+
* should never issue the request at all.
73+
*/
74+
it('issues no request when disabled', async () => {
75+
mockRequestJson.mockResolvedValue({ sttAvailable: true })
76+
77+
const { getResult } = renderHookWithClient(() => useVoiceSettings({ enabled: false }))
78+
await flush()
79+
80+
expect(mockRequestJson).not.toHaveBeenCalled()
81+
expect(getResult().data).toBeUndefined()
82+
})
83+
84+
/** A failed capability probe must read as unavailable, not throw. */
85+
it('leaves data undefined when the request fails', async () => {
86+
mockRequestJson.mockRejectedValue(new Error('offline'))
87+
88+
const { getResult } = renderHookWithClient(() => useVoiceSettings())
89+
await flush()
90+
91+
expect(getResult().data).toBeUndefined()
92+
expect(getResult().isError).toBe(true)
93+
})
94+
95+
it('dedupes across simultaneous consumers', async () => {
96+
mockRequestJson.mockResolvedValue({ sttAvailable: true })
97+
98+
renderHookWithClient(() => {
99+
useVoiceSettings()
100+
useVoiceSettings()
101+
return null
102+
})
103+
await flush()
104+
105+
expect(mockRequestJson).toHaveBeenCalledTimes(1)
106+
})
107+
})

apps/sim/hooks/queries/voice.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
export function useVoiceSettings(options?: { enabled?: boolean }) {
36+
return useQuery({
37+
queryKey: voiceSettingsKeys.settings(),
38+
queryFn: ({ signal }) => fetchSttAvailable(signal),
39+
enabled: options?.enabled ?? true,
40+
staleTime: VOICE_SETTINGS_STALE_TIME,
41+
})
42+
}

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

Lines changed: 14 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

@@ -45,7 +45,19 @@ export function useSpeechToText({
4545
workspaceId,
4646
}: UseSpeechToTextProps): UseSpeechToTextReturn {
4747
const [isListening, setIsListening] = useState(false)
48-
const [isSupported, setIsSupported] = useState(false)
48+
49+
/**
50+
* Gate the capability request on the browser APIs streaming needs, so clients
51+
* that could not use STT anyway never issue it.
52+
*/
53+
const browserSupportsAudioCapture =
54+
typeof window !== 'undefined' &&
55+
typeof AudioContext !== 'undefined' &&
56+
typeof WebSocket !== 'undefined' &&
57+
typeof navigator?.mediaDevices?.getUserMedia === 'function'
58+
59+
const { data: sttAvailable } = useVoiceSettings({ enabled: browserSupportsAudioCapture })
60+
const isSupported = browserSupportsAudioCapture && sttAvailable === true
4961
const [permissionState, setPermissionState] = useState<PermissionState>('prompt')
5062

5163
const onTranscriptRef = useRef(onTranscript)
@@ -72,27 +84,6 @@ export function useSpeechToText({
7284
languageRef.current = language
7385
workspaceIdRef.current = workspaceId
7486

75-
useEffect(() => {
76-
const browserOk =
77-
typeof window !== 'undefined' &&
78-
typeof AudioContext !== 'undefined' &&
79-
typeof WebSocket !== 'undefined' &&
80-
typeof navigator?.mediaDevices?.getUserMedia === 'function'
81-
82-
if (!browserOk) {
83-
setIsSupported(false)
84-
return
85-
}
86-
87-
requestJson(getVoiceSettingsContract, {})
88-
.then((data) => {
89-
if (mountedRef.current) setIsSupported(data.sttAvailable === true)
90-
})
91-
.catch(() => {
92-
if (mountedRef.current) setIsSupported(false)
93-
})
94-
}, [])
95-
9687
const flushAudioBuffer = useCallback(() => {
9788
const ws = wsRef.current
9889
if (!ws || ws.readyState !== WebSocket.OPEN) return

0 commit comments

Comments
 (0)