From eac5699bcdd359f59fb552f2ce52209c852313ac Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 27 Oct 2026 23:06:36 -0700 Subject: [PATCH] perf(mobile): pause active turn clocks while backgrounded (#34746) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- ...bile-native-chat-scroll-ownership.test.tsx | 1 + .../MobileNativeChatMessage.reasoning.test.ts | 1 + .../MobileNativeChatMessage.test.ts | 1 + ...tMessage.tool-result-availability.test.tsx | 1 + .../MobileNativeChatTurnStatus.test.ts | 93 +++++++++++++++++++ .../transcript/MobileNativeChatTurnStatus.tsx | 13 +-- 6 files changed, 100 insertions(+), 10 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx b/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx index e9a65c973fc..51e0a9331c4 100644 --- a/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx +++ b/mobile/src/session/mobile-native-chat-scroll-ownership.test.tsx @@ -57,6 +57,7 @@ vi.mock('react-native', async () => { }) const animation = () => ({ start() {}, stop() {} }) return { + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, View, Text: View, Pressable: View, diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.reasoning.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.reasoning.test.ts index c2373744f74..8c030f29976 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.reasoning.test.ts +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.reasoning.test.ts @@ -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, diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts index 51d61e0fd87..0cf65215810 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts @@ -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, diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx index 45118f8c270..1646014a8fe 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx @@ -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, diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts index 1b9c5b8c376..43bf961fa12 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.test.ts @@ -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) => 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) + }) }) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.tsx index 207eb90ae9d..f17f121e7fe 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatTurnStatus.tsx @@ -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 }