mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
perf(mobile): pause active turn clocks while backgrounded (#34746)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user