From bca419727a254aa0dbbaca7c9ac2c89f0c7301c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 16:58:28 -0700 Subject: [PATCH] perf(mobile): pause elapsed-time timers on hidden thread screens Retained thread routes kept their 1s working/subagent timers running while unfocused or backgrounded. useVisibleSecondClock ticks only while enabled, focused and active, and re-reads the clock before paint on resume. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/package.json | 1 + .../features/threads/ProviderSubagentBar.tsx | 9 +- .../threads/floating-working-control.tsx | 7 +- .../threads/use-visible-second-clock.test.tsx | 199 ++++++++++++++++++ .../threads/use-visible-second-clock.ts | 34 +++ pnpm-lock.yaml | 3 + 6 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.test.tsx create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ca295e389fe2..b8d8dea0d058 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -135,6 +135,7 @@ "@types/react": "~19.3.0", "@types/react-dom": "~19.2.3", "babel-preset-expo": "~58.0.8", + "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx index 813b64148901..fc93df0cd7a9 100644 --- a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -3,12 +3,12 @@ import { type ProviderSubagentStatus, } from "@t3tools/client-runtime/state/thread-execution"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; -import { useEffect, useState } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { RequestActionButton } from "./RequestActionButton"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; /** * Replaces the composer on a provider-native subagent thread. The provider @@ -26,12 +26,7 @@ export function ProviderSubagentBar(props: { readonly onOpenParent: (() => void) | null; }) { const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - if (!live) return; - const id = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(id); - }, [live]); + const nowMs = useVisibleSecondClock(live); const statusLabel = formatProviderSubagentStatus(props.status, nowMs); const modelDescription = props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 2e032eb8ba5e..f21a83f09a17 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -30,6 +30,7 @@ import { BrowserPreviewButton } from "../browser/browser-preview-button"; import { DevicePreviewButton } from "../devices/device-preview-button"; import type { FloatingWorkingStatus } from "./floating-working-status"; import { ShimmeringWorkContent } from "./thread-work-log"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so @@ -510,11 +511,7 @@ function WorkingDuration(props: { } export function WorkingTimer(props: { readonly startedAt: string }) { - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, []); + const nowMs = useVisibleSecondClock(true); return ( { + const listeners = new Set<() => void>(); + const appStateListeners = new Set<(state: string) => void>(); + const state = { focused: true, appState: "active" }; + return { + state, + appStateListeners, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setFocused(focused: boolean) { + state.focused = focused; + for (const listener of listeners) listener(); + }, + setAppState(next: string) { + state.appState = next; + for (const listener of appStateListeners) listener(next); + }, + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused), +})); +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return harness.state.appState; + }, + addEventListener: (_event: "change", listener: (state: string) => void) => { + harness.appStateListeners.add(listener); + return { remove: () => harness.appStateListeners.delete(listener) }; + }, + }, +})); + +import { useVisibleSecondClock } from "./use-visible-second-clock"; + +const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0); +let root: Root | null = null; +// Clock value of every committed render, the work a tick costs on device. +let renders: number[] = []; + +function Probe(props: { readonly enabled: boolean }) { + const nowMs = useVisibleSecondClock(props.enabled); + useEffect(() => { + renders.push(nowMs); + }); + return null; +} + +function mount(enabled: boolean) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => root!.render()); +} + +// One act per second, so each tick commits its own render as it would on device. +function advance(ms: number) { + for (let elapsed = 0; elapsed < ms; elapsed += 1_000) { + act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed))); + } +} + +function latest() { + return renders.at(-1); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + harness.state.focused = true; + harness.state.appState = "active"; + renders = []; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.useRealTimers(); +}); + +describe("useVisibleSecondClock", () => { + it("ticks once a second while enabled, focused and active", () => { + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(60); + expect(latest()).toBe(START_MS + 60_000); + }); + + it("does not wake while the app is in the background", () => { + harness.state.appState = "background"; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake while the screen is unfocused", () => { + harness.state.focused = false; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake when disabled, even when visible", () => { + mount(false); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + }); + + it("notices an AppState change between first render and subscribing", () => { + // Layout effects run before the hook's passive subscription, so this change has no listener. + function BackgroundBeforeSubscribe() { + useLayoutEffect(() => harness.setAppState("background"), []); + return null; + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => + root!.render( + <> + + + , + ), + ); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => { + mount(true); + advance(5_000); + act(() => harness.setFocused(false)); + const paused = renders.length; + advance(120_000); + expect(renders.length).toBe(paused); + + act(() => harness.setFocused(true)); + expect(latest()).toBe(START_MS + 125_000); + advance(1_000); + expect(latest()).toBe(START_MS + 126_000); + }); + + it("pauses while backgrounded and catches up on the first active render", () => { + mount(true); + act(() => harness.setAppState("background")); + const paused = renders.length; + advance(600_000); + expect(renders.length).toBe(paused); + expect(vi.getTimerCount()).toBe(0); + + act(() => harness.setAppState("inactive")); + advance(10_000); + expect(renders.length).toBe(paused); + + act(() => harness.setAppState("active")); + expect(latest()).toBe(START_MS + 610_000); + const resumed = renders.length; + advance(60_000); + expect(renders.length - resumed).toBe(60); + }); + + it("stops when the work it measures settles and refreshes when new work starts", () => { + mount(true); + advance(3_000); + act(() => root!.render()); + const settled = renders.length; + advance(60_000); + expect(renders.length).toBe(settled); + + act(() => root!.render()); + expect(latest()).toBe(START_MS + 63_000); + }); + + it("clears its interval and AppState subscription on unmount", () => { + mount(true); + expect(vi.getTimerCount()).toBe(1); + expect(harness.appStateListeners.size).toBe(1); + act(() => root!.unmount()); + root = null; + expect(vi.getTimerCount()).toBe(0); + expect(harness.appStateListeners.size).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/use-visible-second-clock.ts b/apps/mobile/src/features/threads/use-visible-second-clock.ts new file mode 100644 index 000000000000..ff98092b231b --- /dev/null +++ b/apps/mobile/src/features/threads/use-visible-second-clock.ts @@ -0,0 +1,34 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { AppState } from "react-native"; + +/** + * Wall clock for elapsed-time labels, advanced once a second only while + * `enabled`, the screen is focused, and the app is active. Retained routes stay + * mounted when hidden, so an ungated interval would keep re-rendering them. + * The clock re-reads the time before paint when ticking resumes, so a label + * never shows the moment it paused. + */ +export function useVisibleSecondClock(enabled: boolean): number { + const focused = useIsFocused(); + const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setAppActive(state === "active"), + ); + // A change between the first render and this subscription has no event to replay. + // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed. + setAppActive(AppState.currentState === "active"); + return () => subscription.remove(); + }, []); + const ticking = enabled && focused && appActive; + useLayoutEffect(() => { + if (!ticking) return; + // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still. + setNowMs(Date.now()); + const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(intervalId); + }, [ticking]); + return nowMs; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a54dc0d1fa2..bf0256cee1cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: babel-preset-expo: specifier: ~58.0.8 version: 58.0.8(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@58.0.11)(expo@58.0.2)(react-refresh@0.14.2) + jsdom: + specifier: ^30.0.1 + version: 30.1.0(@noble/hashes@1.8.0) tailwindcss: specifier: 4.3.3 version: 4.3.3