From 01bcc57ff68065c866bec0fec5ba3a4bd9a85455 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:22:01 -0700 Subject: [PATCH] perf(mobile): gate dictation setup progress polling on foreground + single-flight (#9892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): gate dictation setup polling Co-authored-by: Orca * 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 --------- Co-authored-by: Orca --- mobile/app/voice-settings.tsx | 68 +++--- .../components/MobileDictationSetupSheet.tsx | 45 ++-- .../dictation-setup-poll-controller.test.ts | 197 ++++++++++++++++++ .../dictation-setup-poll-controller.ts | 153 ++++++++++++++ .../dictation/use-dictation-setup-poller.ts | 54 +++++ 5 files changed, 459 insertions(+), 58 deletions(-) create mode 100644 mobile/src/dictation/dictation-setup-poll-controller.test.ts create mode 100644 mobile/src/dictation/dictation-setup-poll-controller.ts create mode 100644 mobile/src/dictation/use-dictation-setup-poller.ts diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index a6c725c0e1c..4a0f6d07c79 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -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(null) const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) - const pollRef = useRef | null>(null) + const [routeFocused, setRouteFocused] = useState(false) - const refresh = useCallback(async () => { + useFocusEffect( + useCallback(() => { + setRouteFocused(true) + return () => setRouteFocused(false) + }, []) + ) + + const refresh = useCallback(async (): Promise => { 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( diff --git a/mobile/src/components/MobileDictationSetupSheet.tsx b/mobile/src/components/MobileDictationSetupSheet.tsx index 7e8e221aab2..f4a81c02b8f 100644 --- a/mobile/src/components/MobileDictationSetupSheet.tsx +++ b/mobile/src/components/MobileDictationSetupSheet.tsx @@ -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(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(null) - const pollRef = useRef | null>(null) - - const refresh = useCallback(async () => { + const refresh = useCallback(async (): Promise => { 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( diff --git a/mobile/src/dictation/dictation-setup-poll-controller.test.ts b/mobile/src/dictation/dictation-setup-poll-controller.test.ts new file mode 100644 index 00000000000..5ca010759d6 --- /dev/null +++ b/mobile/src/dictation/dictation-setup-poll-controller.test.ts @@ -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 { + // 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(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + return { + promise: new Promise((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(), deferred(), deferred()] + 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(), deferred()] + 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() + 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() + }) +}) diff --git a/mobile/src/dictation/dictation-setup-poll-controller.ts b/mobile/src/dictation/dictation-setup-poll-controller.ts new file mode 100644 index 00000000000..cb927f161f9 --- /dev/null +++ b/mobile/src/dictation/dictation-setup-poll-controller.ts @@ -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 | 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, + 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 { + 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): 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 { + // 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() + } + } +} diff --git a/mobile/src/dictation/use-dictation-setup-poller.ts b/mobile/src/dictation/use-dictation-setup-poller.ts new file mode 100644 index 00000000000..407eded9f6f --- /dev/null +++ b/mobile/src/dictation/use-dictation-setup-poller.ts @@ -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 + intervalMs: number +} + +export function useDictationSetupPoller({ + visible, + polling, + refresh, + intervalMs +}: PollerOptions): () => Promise { + 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]) +}