fix(mobile): pause relative-time clocks when hidden (#9886)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-22 14:13:02 -07:00 committed by GitHub
parent 76f5b8318c
commit a3d6f84286
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 157 additions and 15 deletions

View File

@ -9,13 +9,14 @@ import {
Alert
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { styles } from './accounts-screen-styles'
import { useNow } from '../../../src/hooks/use-now'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
@ -40,14 +41,16 @@ export default function AccountsScreen() {
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
const [clockEnabled, setClockEnabled] = useState(false)
// Why: the reset countdown must stay fresh while the screen sits open —
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(id)
}, [])
useFocusEffect(
useCallback(() => {
setClockEnabled(true)
return () => setClockEnabled(false)
}, [])
)
// Why: snapshot pushes only arrive when the desktop's rate-limit poll completes.
const now = useNow(60_000, clockEnabled)
useEffect(() => {
if (!hostId) {

View File

@ -0,0 +1,101 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
const appState = vi.hoisted(() => ({
current: 'active',
listener: null as ((nextState: string) => void) | null,
remove: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: {
get currentState(): string {
return appState.current
},
addEventListener: (_event: string, listener: (nextState: string) => void) => {
appState.listener = listener
return { remove: appState.remove }
}
}
}))
import { useNow } from './use-now'
describe('useNow', () => {
let renderer: ReactTestRenderer | null = null
let latest = 0
let consoleSpy: MockInstance
function Harness({ enabled = true }: { enabled?: boolean }): null {
latest = useNow(1_000, enabled)
return null
}
function changeAppState(nextState: string): void {
act(() => {
appState.current = nextState
appState.listener?.(nextState)
})
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
globalThis.IS_REACT_ACT_ENVIRONMENT = true
appState.current = 'active'
appState.listener = null
appState.remove.mockClear()
latest = 0
const original = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
act(() => {
renderer = create(createElement(Harness))
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
consoleSpy.mockRestore()
})
it('ticks while active, pauses in the background, and refreshes immediately on resume', () => {
expect(latest).toBe(1_000)
act(() => vi.advanceTimersByTime(1_000))
expect(latest).toBe(2_000)
changeAppState('background')
act(() => vi.advanceTimersByTime(5_000))
expect(latest).toBe(2_000)
changeAppState('active')
expect(latest).toBe(7_000)
act(() => vi.advanceTimersByTime(1_000))
expect(latest).toBe(8_000)
})
it('stops while disabled and refreshes immediately when re-enabled', () => {
act(() => renderer?.update(createElement(Harness, { enabled: false })))
act(() => vi.advanceTimersByTime(5_000))
expect(latest).toBe(1_000)
act(() => renderer?.update(createElement(Harness, { enabled: true })))
expect(latest).toBe(6_000)
})
it('removes the shared AppState listener after the last caller unmounts', () => {
act(() => renderer?.unmount())
renderer = null
expect(appState.remove).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,14 +1,52 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { AppState, type AppStateStatus } from 'react-native'
// One shared interval per caller, mirroring desktop's useNow: relative
// timestamps ("Xm") need a periodic re-render to stay honest. The worktree list
// owns a single tick that drives every visible agent row, rather than each row
// running its own interval.
export function useNow(intervalMs = 30_000): number {
const appStateListeners = new Set<() => void>()
let currentAppState: AppStateStatus | null = AppState.currentState
let appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null
function subscribeToAppState(listener: () => void): () => void {
appStateListeners.add(listener)
if (!appStateSubscription) {
currentAppState = AppState.currentState
appStateSubscription = AppState.addEventListener('change', (nextState) => {
currentAppState = nextState
appStateListeners.forEach((notify) => notify())
})
}
return () => {
appStateListeners.delete(listener)
if (appStateListeners.size === 0) {
appStateSubscription?.remove()
appStateSubscription = null
}
}
}
function isAppActive(): boolean {
return (appStateSubscription ? currentAppState : AppState.currentState) === 'active'
}
// A list-level caller's single tick drives every visible relative-time label.
export function useNow(intervalMs = 30_000, enabled = true): number {
const appActive = useSyncExternalStore(subscribeToAppState, isAppActive, isAppActive)
const running = appActive && enabled
const [now, setNow] = useState(() => Date.now())
const wasRunningRef = useRef(running)
useEffect(() => {
const resumed = running && !wasRunningRef.current
wasRunningRef.current = running
if (!running) {
return
}
if (resumed) {
setNow(Date.now())
}
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
}, [intervalMs, running])
return now
}