fix(mobile): pause relative-time clocks when hidden (#9886)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
76f5b8318c
commit
a3d6f84286
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue