perf(mobile): pause active turn clocks while backgrounded (#34746)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-27 23:06:36 -07:00
committed by GitHub
co-authored by OrcaWin
parent 6fcbce32b9
commit eac5699bcd
6 changed files with 100 additions and 10 deletions
@@ -57,6 +57,7 @@ vi.mock('react-native', async () => {
})
const animation = () => ({ start() {}, stop() {} })
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
View,
Text: View,
Pressable: View,
@@ -9,6 +9,7 @@ vi.mock('react-native', async () => {
const Text = ({ children, ...props }: { children?: ReactNode }): unknown =>
React.createElement('Text', props, children)
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
@@ -17,6 +17,7 @@ vi.mock('react-native', async () => {
const Text = ({ children, ...props }: { children?: ReactNode }): unknown =>
React.createElement('Text', props, children)
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
@@ -16,6 +16,7 @@ vi.mock('react-native', async () => {
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
React.createElement('Text', props, children)
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
@@ -1,12 +1,31 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppStateStatus } from 'react-native'
const appState = vi.hoisted(() => ({
current: 'active',
listeners: new Set<(state: AppStateStatus) => void>()
}))
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: ReactNode }): unknown =>
React.createElement('Text', props, children)
return {
AppState: {
get currentState() {
return appState.current
},
addEventListener: (_event: string, listener: (state: AppStateStatus) => void) => {
appState.listeners.add(listener)
return {
remove: () => {
appState.listeners.delete(listener)
}
}
}
},
ActivityIndicator: (props: Record<string, unknown>) =>
React.createElement('ActivityIndicator', props),
Pressable: ({ children, ...props }: { children?: ReactNode }) =>
@@ -36,15 +55,26 @@ let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-04T00:00:00Z'))
appState.current = 'active'
appState.listeners.clear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('MobileNativeChatTurnStatus', () => {
function changeAppState(state: AppStateStatus) {
act(() => {
appState.current = state
for (const listener of appState.listeners) {
listener(state)
}
})
}
function render(props: {
startedAt: number | null
workedSeconds?: number | null
@@ -107,4 +137,67 @@ describe('MobileNativeChatTurnStatus', () => {
render({ startedAt: Date.now(), workedSeconds: 5 })
expect(vi.getTimerCount()).toBe(0)
})
it('stops clock callbacks in the background and catches up immediately on resume', () => {
const tree = render({ startedAt: Date.now() })
act(() => {
vi.advanceTimersByTime(2_000)
})
expect(labels(tree.root)).toEqual(['Working for 2s'])
changeAppState('background')
const clockReads = vi.spyOn(Date, 'now')
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(clockReads).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
expect(labels(tree.root)).toEqual(['Working for 2s'])
changeAppState('active')
expect(labels(tree.root)).toEqual(['Working for 1m 2s'])
expect(vi.getTimerCount()).toBe(1)
act(() => {
vi.advanceTimersByTime(1_000)
})
expect(labels(tree.root)).toEqual(['Working for 1m 3s'])
})
it('keeps the original pre-stamp epoch through background and settlement transitions', () => {
const tree = render({ startedAt: null })
act(() => {
vi.advanceTimersByTime(3_000)
})
expect(labels(tree.root)).toEqual(['Working for 3s'])
changeAppState('background')
act(() => {
vi.advanceTimersByTime(10_000)
})
act(() =>
tree.update(createElement(MobileNativeChatTurnStatus, { startedAt: null, workedSeconds: 8 }))
)
changeAppState('active')
expect(labels(tree.root)).toEqual(['Worked for 8s'])
expect(vi.getTimerCount()).toBe(0)
act(() => tree.update(createElement(MobileNativeChatTurnStatus, { startedAt: null })))
expect(labels(tree.root)).toEqual(['Working for 13s'])
expect(vi.getTimerCount()).toBe(1)
act(() => tree.unmount())
expect(vi.getTimerCount()).toBe(0)
expect(appState.listeners.size).toBe(0)
})
it('creates no clock while initially backgrounded and uses a supplied start time on resume', () => {
appState.current = 'background'
const startedAt = Date.now() - 5_000
const tree = render({ startedAt })
expect(vi.getTimerCount()).toBe(0)
act(() => {
vi.advanceTimersByTime(15_000)
})
changeAppState('active')
expect(labels(tree.root)).toEqual(['Working for 20s'])
expect(vi.getTimerCount()).toBe(1)
})
})
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronRight } from 'lucide-react-native'
import {
@@ -10,20 +10,13 @@ import type { AgentTurnOutcome } from '../../../../../src/shared/agent-turn-outc
import { colors, spacing, typography } from '../../../theme/mobile-theme'
import { themedStyleSheet } from '../../../theme/themed-styles'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { useNow } from '../../../hooks/use-now'
// Stop ticking settled turns so old transcripts retain no timers.
function useElapsedSeconds(startedAt: number | null, counting: boolean): number {
// Preserves the pre-stamp epoch for the frame before the turn's startedAt lands.
const [mountedAt] = useState(() => Date.now())
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!counting) {
return
}
setNow(Date.now())
const timer = setInterval(() => setNow(Date.now()), 1_000)
return () => clearInterval(timer)
}, [counting])
const now = useNow(1_000, counting)
return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0
}