perf(mobile): gate dictation setup progress polling on foreground + single-flight (#9892)

* fix(mobile): gate dictation setup polling

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): fence a stale dictation refresh against a newer setPolling intent

An in-flight setup read resolving 'keep polling' after an explicit setPolling(false)
wrote polling=true and rescheduled, resurrecting a poll the caller had just stopped.
Snapshot a pollingRevision when each read starts and only apply its result if no
explicit setPolling superseded it mid-flight — so a late true can't restart a stopped
poll (nor a late false cancel a restart).

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-22 16:22:01 -07:00
committed by GitHub
co-authored by Orca
parent 6a43f9935d
commit 01bcc57ff6
5 changed files with 459 additions and 58 deletions
+35 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ActivityIndicator,
Pressable,
@@ -9,7 +9,7 @@ import {
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
@@ -18,6 +18,7 @@ import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller'
import {
deleteDictationModel,
downloadDictationModel,
@@ -58,44 +59,45 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [routeFocused, setRouteFocused] = useState(false)
const refresh = useCallback(async () => {
useFocusEffect(
useCallback(() => {
setRouteFocused(true)
return () => setRouteFocused(false)
}, [])
)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return
return false
}
try {
setSetup(await fetchDictationSetup(client))
const next = await fetchDictationSetup(client)
setSetup(next)
setError(null)
return next.models.some(isModelInFlight)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load voice settings')
return undefined
} finally {
setLoading(false)
}
}, [client])
// Initial load once a connected client is available.
useEffect(() => {
if (!client) {
return
}
setLoading(true)
setError(null)
void refresh().finally(() => setLoading(false))
}, [client, refresh])
const polling = setup?.models.some(isModelInFlight) ?? false
const refreshSetup = useDictationSetupPoller({
visible: routeFocused && client !== null,
polling,
refresh,
intervalMs: POLL_INTERVAL_MS
})
// Poll only while a model is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
if (routeFocused && client && setup === null) {
setLoading(true)
}
return undefined
}, [setup, client, refresh])
}, [routeFocused, client, setup])
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
@@ -109,10 +111,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setSetup(await setDictationConfig(client, { enabled }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
void refreshSetup()
}
},
[client, refresh]
[client, refreshSetup]
)
const handleSelectMode = useCallback(
@@ -126,10 +128,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setSetup(await setDictationConfig(client, { dictationMode }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
void refreshSetup()
}
},
[client, refresh]
[client, refreshSetup]
)
const handleUseModel = useCallback(
@@ -160,14 +162,14 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
await refreshSetup()
} catch (err) {
setError(err instanceof Error ? err.message : 'Download failed')
} finally {
setBusyAction(null)
}
},
[client, refresh]
[client, refreshSetup]
)
const handleDelete = useCallback(
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native'
import { Check, Download } from 'lucide-react-native'
import { BottomDrawer } from './BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller'
import {
downloadDictationModel,
fetchDictationSetup,
@@ -37,40 +38,34 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const refresh = useCallback(async () => {
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return
return false
}
try {
setSetup(await fetchDictationSetup(client))
const next = await fetchDictationSetup(client)
setSetup(next)
setError(null)
return next.models.some(isModelInFlight)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load')
return undefined
}
}, [client])
const polling = setup?.models.some(isModelInFlight) ?? false
const refreshSetup = useDictationSetupPoller({
visible: visible && client !== null,
polling,
refresh,
intervalMs: POLL_INTERVAL_MS
})
useEffect(() => {
if (visible) {
setError(null)
void refresh()
}
}, [visible, refresh])
// Poll only while something is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (visible && inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}
return undefined
}, [visible, setup, client, refresh])
}, [visible])
const handleDownload = useCallback(
async (model: MobileSpeechModel) => {
@@ -81,7 +76,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
await refreshSetup()
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Download failed')
@@ -89,7 +84,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
setBusy(null)
}
},
[client, refresh]
[client, refreshSetup]
)
const handleUseModel = useCallback(
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DictationSetupPollController } from './dictation-setup-poll-controller'
const POLL_INTERVAL_MS = 1500
async function flushPromises(): Promise<void> {
// Why: the refresh mock wraps its result in `.finally()` and the resume path chains
// runRefresh → requestRefresh → runRefresh, so the follow-up refresh is several microtask
// hops deep — drain generously rather than a fixed two ticks.
for (let i = 0; i < 8; i += 1) {
await Promise.resolve()
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
return {
promise: new Promise<T>((next) => {
resolve = next
}),
resolve
}
}
describe('DictationSetupPollController', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('does not refresh while hidden, unfocused, or backgrounded', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setForeground(true)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).not.toHaveBeenCalled()
poller.setVisible(true)
expect(refresh).toHaveBeenCalledOnce()
await flushPromises()
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).toHaveBeenCalledOnce()
poller.setVisible(true)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
poller.setForeground(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).toHaveBeenCalledTimes(2)
poller.dispose()
})
it('keeps slow refreshes single-flight and waits a full delay after each response', async () => {
const requests = [deferred<boolean>(), deferred<boolean>(), deferred<boolean>()]
let active = 0
let maxActive = 0
const refresh = vi.fn(() => {
const request = requests[refresh.mock.calls.length - 1]
active += 1
maxActive = Math.max(maxActive, active)
return request.promise.finally(() => {
active -= 1
})
})
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(refresh).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledTimes(2)
requests[1].resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1)
expect(refresh).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(3)
expect(maxActive).toBe(1)
requests[2].resolve(false)
await flushPromises()
poller.dispose()
})
it('coalesces an immediate resume refresh behind a slow request', async () => {
const requests = [deferred<boolean>(), deferred<boolean>()]
let active = 0
let maxActive = 0
const refresh = vi.fn(() => {
const request = requests[refresh.mock.calls.length - 1]
active += 1
maxActive = Math.max(maxActive, active)
return request.promise.finally(() => {
active -= 1
})
})
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
poller.setForeground(false)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(2)
expect(maxActive).toBe(1)
requests[1].resolve(false)
await flushPromises()
poller.dispose()
})
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
await flushPromises()
poller.setForeground(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
poller.setVisible(true)
expect(refresh).toHaveBeenCalledTimes(3)
poller.dispose()
})
it('stops after setup leaves the download or extraction lifecycle', async () => {
const refresh = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
poller.dispose()
})
it('does not resurrect polling when an in-flight refresh resolves true after setPolling(false)', async () => {
const request = deferred<boolean>()
const refresh = vi.fn(() => request.promise)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
// Explicit stop lands while the read is still on the wire.
poller.setPolling(false)
// The stale read then resolves "keep polling" — the fence must drop it, not restart the poll.
request.resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
poller.dispose()
})
})
@@ -0,0 +1,153 @@
type PollState = {
visible: boolean
foreground: boolean
polling: boolean
}
type RefreshResult = boolean | undefined
export class DictationSetupPollController {
private state: PollState = { visible: false, foreground: false, polling: false }
private timer: ReturnType<typeof setTimeout> | null = null
private inFlight = false
private immediateRefreshPending = false
private refreshWaiters: Array<() => void> = []
private disposed = false
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
// fenced out instead of clobbering that intent (e.g. a late `true` resurrecting a just-stopped poll).
private pollingRevision = 0
constructor(
private readonly refresh: () => Promise<RefreshResult>,
private readonly intervalMs: number
) {}
setVisible(visible: boolean): void {
this.update({ visible })
}
setForeground(foreground: boolean): void {
this.update({ foreground })
}
setPolling(polling: boolean): void {
this.pollingRevision += 1
this.update({ polling })
}
refreshNow(): Promise<void> {
if (this.disposed || !this.isEligible()) {
return Promise.resolve()
}
return new Promise((resolve) => {
this.refreshWaiters.push(resolve)
this.requestRefresh(true)
})
}
dispose(): void {
this.disposed = true
this.immediateRefreshPending = false
this.clearTimer()
this.resolveRefreshWaiters()
}
private update(next: Partial<PollState>): void {
if (this.disposed) {
return
}
const wasEligible = this.isEligible()
const wasPolling = this.state.polling
this.state = { ...this.state, ...next }
if (!this.isEligible()) {
this.immediateRefreshPending = false
this.clearTimer()
return
}
if (!wasEligible) {
this.requestRefresh(true)
return
}
if (!this.state.polling) {
this.clearTimer()
return
}
if (!wasPolling) {
this.scheduleRefresh()
}
}
private isEligible(): boolean {
return this.state.visible && this.state.foreground
}
private requestRefresh(immediate: boolean): void {
if (this.inFlight) {
this.immediateRefreshPending ||= immediate
return
}
this.clearTimer()
this.inFlight = true
void this.runRefresh()
}
private async runRefresh(): Promise<void> {
// Snapshot the lifecycle intent this read is answering; an explicit setPolling during the read makes
// its result stale.
const revisionAtStart = this.pollingRevision
let shouldContinue: RefreshResult
try {
shouldContinue = await this.refresh()
} catch {
// A transient read failure preserves the current lifecycle for a later retry.
shouldContinue = undefined
} finally {
this.inFlight = false
}
// Fence: only let the read drive polling if no explicit setPolling superseded it mid-flight, so a
// late `true` can't resurrect a poll the caller just stopped (nor a late `false` cancel a restart).
if (shouldContinue !== undefined && this.pollingRevision === revisionAtStart) {
this.state.polling = shouldContinue
}
if (this.disposed || !this.isEligible()) {
this.resolveRefreshWaiters()
return
}
if (this.immediateRefreshPending) {
this.immediateRefreshPending = false
this.requestRefresh(true)
return
}
this.resolveRefreshWaiters()
if (this.state.polling) {
this.scheduleRefresh()
}
}
private scheduleRefresh(): void {
if (this.timer !== null || this.inFlight || !this.isEligible() || !this.state.polling) {
return
}
this.timer = setTimeout(() => {
this.timer = null
this.requestRefresh(false)
}, this.intervalMs)
}
private clearTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
}
private resolveRefreshWaiters(): void {
const waiters = this.refreshWaiters.splice(0)
for (const resolve of waiters) {
resolve()
}
}
}
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { AppState } from 'react-native'
import { DictationSetupPollController } from './dictation-setup-poll-controller'
type PollerOptions = {
visible: boolean
polling: boolean
refresh: () => Promise<boolean | undefined>
intervalMs: number
}
export function useDictationSetupPoller({
visible,
polling,
refresh,
intervalMs
}: PollerOptions): () => Promise<void> {
const refreshRef = useRef(refresh)
refreshRef.current = refresh
const poller = useMemo(
() => new DictationSetupPollController(() => refreshRef.current(), intervalMs),
[intervalMs]
)
useEffect(() => () => poller.dispose(), [poller])
useEffect(() => {
void poller.refreshNow()
}, [poller, refresh])
useEffect(() => {
poller.setPolling(polling)
}, [poller, polling])
useEffect(() => {
poller.setVisible(visible)
if (!visible) {
poller.setForeground(false)
return undefined
}
poller.setForeground(AppState.currentState === 'active')
const subscription = AppState.addEventListener('change', (state) => {
poller.setForeground(state === 'active')
})
return () => {
subscription.remove()
poller.setVisible(false)
poller.setForeground(false)
}
}, [poller, visible])
return useCallback(() => poller.refreshNow(), [poller])
}