mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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.
This commit is contained in:
@@ -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<T>(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' })
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @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 { afterEach, 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'
|
||||
|
||||
/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */
|
||||
const mountedRoots: Root[] = []
|
||||
|
||||
function renderHookWithClient<T>(
|
||||
useHook: () => T,
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
): { getResult: () => T } {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
mountedRoots.push(root)
|
||||
let result: T | undefined
|
||||
|
||||
function Probe() {
|
||||
result = useHook()
|
||||
return null
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>{(<Probe />) as ReactNode}</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const root of mountedRoots.splice(0)) root.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
|
||||
renderHookWithClient(() => {
|
||||
useVoiceSettings()
|
||||
useVoiceSettings()
|
||||
return null
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
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<boolean> {
|
||||
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.
|
||||
*
|
||||
* `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({
|
||||
queryKey: voiceSettingsKeys.settings(),
|
||||
queryFn: ({ signal }) => fetchSttAvailable(signal),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: VOICE_SETTINGS_STALE_TIME,
|
||||
retryOnMount: true,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user