authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 22:30:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 22:30:48-07:00
logeb17e0db2bd075e71b4015dc57588e7f838cbcdc
tree6b14e3be5d18deafd8ff8dadee26feb943b01214
parent28f5516a9acfcfc1e203487e78869a406fd23b76
signature Signed by PGP key B5690EEEBB952194

perf(mobile): pause elapsed-time timers on hidden thread screens (#15397)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

6 files changed, 241 insertions(+), 12 deletions(-)

apps/mobile/package.json+1
......@@ -135,6 +135,7 @@
135135 "@types/react": "~19.3.0",
136136 "@types/react-dom": "~19.2.3",
137137 "babel-preset-expo": "~58.0.8",
138 "jsdom": "^30.0.1",
138139 "tailwindcss": "^4.0.0",
139140 "typescript": "catalog:"
140141 },
apps/mobile/src/features/threads/ProviderSubagentBar.tsx+2-7
......@@ -3,12 +3,12 @@ import {
33 type ProviderSubagentStatus,
44} from "@t3tools/client-runtime/state/thread-execution";
55import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
6import { useEffect, useState } from "react";
76import { View } from "react-native";
87
98import { AppText as Text } from "../../components/AppText";
109import { ProviderIcon } from "../../components/ProviderIcon";
1110import { RequestActionButton } from "./RequestActionButton";
11import { useVisibleSecondClock } from "./use-visible-second-clock";
1212
1313/**
1414 * Replaces the composer on a provider-native subagent thread. The provider
......@@ -26,12 +26,7 @@ export function ProviderSubagentBar(props: {
2626 readonly onOpenParent: (() => void) | null;
2727}) {
2828 const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status);
29 const [nowMs, setNowMs] = useState(() => Date.now());
30 useEffect(() => {
31 if (!live) return;
32 const id = setInterval(() => setNowMs(Date.now()), 1_000);
33 return () => clearInterval(id);
34 }, [live]);
29 const nowMs = useVisibleSecondClock(live);
3530 const statusLabel = formatProviderSubagentStatus(props.status, nowMs);
3631 const modelDescription =
3732 props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`;
apps/mobile/src/features/threads/floating-working-control.tsx+2-5
......@@ -30,6 +30,7 @@ import { BrowserPreviewButton } from "../browser/browser-preview-button";
3030import { DevicePreviewButton } from "../devices/device-preview-button";
3131import type { FloatingWorkingStatus } from "./floating-working-status";
3232import { ShimmeringWorkContent } from "./thread-work-log";
33import { useVisibleSecondClock } from "./use-visible-second-clock";
3334
3435const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem
3536// The collapsed composer capsule starts 6 below its overlay's top edge, so
......@@ -510,11 +511,7 @@ function WorkingDuration(props: {
510511}
511512
512513export function WorkingTimer(props: { readonly startedAt: string }) {
513 const [nowMs, setNowMs] = useState(() => Date.now());
514 useEffect(() => {
515 const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
516 return () => clearInterval(intervalId);
517 }, []);
514 const nowMs = useVisibleSecondClock(true);
518515 return (
519516 <SystemText
520517 className="shrink text-xs text-foreground"
apps/mobile/src/features/threads/use-visible-second-clock.test.tsx created+199
......@@ -0,0 +1,199 @@
1// @vitest-environment jsdom
2import { act, useEffect, useLayoutEffect, useSyncExternalStore } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const harness = vi.hoisted(() => {
7 const listeners = new Set<() => void>();
8 const appStateListeners = new Set<(state: string) => void>();
9 const state = { focused: true, appState: "active" };
10 return {
11 state,
12 appStateListeners,
13 subscribe(listener: () => void) {
14 listeners.add(listener);
15 return () => listeners.delete(listener);
16 },
17 setFocused(focused: boolean) {
18 state.focused = focused;
19 for (const listener of listeners) listener();
20 },
21 setAppState(next: string) {
22 state.appState = next;
23 for (const listener of appStateListeners) listener(next);
24 },
25 };
26});
27
28vi.mock("@react-navigation/native", () => ({
29 useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused),
30}));
31vi.mock("react-native", () => ({
32 AppState: {
33 get currentState() {
34 return harness.state.appState;
35 },
36 addEventListener: (_event: "change", listener: (state: string) => void) => {
37 harness.appStateListeners.add(listener);
38 return { remove: () => harness.appStateListeners.delete(listener) };
39 },
40 },
41}));
42
43import { useVisibleSecondClock } from "./use-visible-second-clock";
44
45const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0);
46let root: Root | null = null;
47// Clock value of every committed render, the work a tick costs on device.
48let renders: number[] = [];
49
50function Probe(props: { readonly enabled: boolean }) {
51 const nowMs = useVisibleSecondClock(props.enabled);
52 useEffect(() => {
53 renders.push(nowMs);
54 });
55 return null;
56}
57
58function mount(enabled: boolean) {
59 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
60 root = createRoot(document.createElement("div"));
61 act(() => root!.render(<Probe enabled={enabled} />));
62}
63
64// One act per second, so each tick commits its own render as it would on device.
65function advance(ms: number) {
66 for (let elapsed = 0; elapsed < ms; elapsed += 1_000) {
67 act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed)));
68 }
69}
70
71function latest() {
72 return renders.at(-1);
73}
74
75beforeEach(() => {
76 vi.useFakeTimers();
77 vi.setSystemTime(START_MS);
78 harness.state.focused = true;
79 harness.state.appState = "active";
80 renders = [];
81});
82
83afterEach(() => {
84 act(() => root?.unmount());
85 root = null;
86 vi.useRealTimers();
87});
88
89describe("useVisibleSecondClock", () => {
90 it("ticks once a second while enabled, focused and active", () => {
91 mount(true);
92 const before = renders.length;
93 advance(60_000);
94 expect(renders.length - before).toBe(60);
95 expect(latest()).toBe(START_MS + 60_000);
96 });
97
98 it("does not wake while the app is in the background", () => {
99 harness.state.appState = "background";
100 mount(true);
101 const before = renders.length;
102 advance(60_000);
103 expect(renders.length - before).toBe(0);
104 expect(vi.getTimerCount()).toBe(0);
105 });
106
107 it("does not wake while the screen is unfocused", () => {
108 harness.state.focused = false;
109 mount(true);
110 const before = renders.length;
111 advance(60_000);
112 expect(renders.length - before).toBe(0);
113 expect(vi.getTimerCount()).toBe(0);
114 });
115
116 it("does not wake when disabled, even when visible", () => {
117 mount(false);
118 const before = renders.length;
119 advance(60_000);
120 expect(renders.length - before).toBe(0);
121 });
122
123 it("notices an AppState change between first render and subscribing", () => {
124 // Layout effects run before the hook's passive subscription, so this change has no listener.
125 function BackgroundBeforeSubscribe() {
126 useLayoutEffect(() => harness.setAppState("background"), []);
127 return null;
128 }
129 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
130 root = createRoot(document.createElement("div"));
131 act(() =>
132 root!.render(
133 <>
134 <BackgroundBeforeSubscribe />
135 <Probe enabled />
136 </>,
137 ),
138 );
139 const before = renders.length;
140 advance(60_000);
141 expect(renders.length - before).toBe(0);
142 expect(vi.getTimerCount()).toBe(0);
143 });
144
145 it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => {
146 mount(true);
147 advance(5_000);
148 act(() => harness.setFocused(false));
149 const paused = renders.length;
150 advance(120_000);
151 expect(renders.length).toBe(paused);
152
153 act(() => harness.setFocused(true));
154 expect(latest()).toBe(START_MS + 125_000);
155 advance(1_000);
156 expect(latest()).toBe(START_MS + 126_000);
157 });
158
159 it("pauses while backgrounded and catches up on the first active render", () => {
160 mount(true);
161 act(() => harness.setAppState("background"));
162 const paused = renders.length;
163 advance(600_000);
164 expect(renders.length).toBe(paused);
165 expect(vi.getTimerCount()).toBe(0);
166
167 act(() => harness.setAppState("inactive"));
168 advance(10_000);
169 expect(renders.length).toBe(paused);
170
171 act(() => harness.setAppState("active"));
172 expect(latest()).toBe(START_MS + 610_000);
173 const resumed = renders.length;
174 advance(60_000);
175 expect(renders.length - resumed).toBe(60);
176 });
177
178 it("stops when the work it measures settles and refreshes when new work starts", () => {
179 mount(true);
180 advance(3_000);
181 act(() => root!.render(<Probe enabled={false} />));
182 const settled = renders.length;
183 advance(60_000);
184 expect(renders.length).toBe(settled);
185
186 act(() => root!.render(<Probe enabled />));
187 expect(latest()).toBe(START_MS + 63_000);
188 });
189
190 it("clears its interval and AppState subscription on unmount", () => {
191 mount(true);
192 expect(vi.getTimerCount()).toBe(1);
193 expect(harness.appStateListeners.size).toBe(1);
194 act(() => root!.unmount());
195 root = null;
196 expect(vi.getTimerCount()).toBe(0);
197 expect(harness.appStateListeners.size).toBe(0);
198 });
199});
apps/mobile/src/features/threads/use-visible-second-clock.ts created+34
......@@ -0,0 +1,34 @@
1import { useIsFocused } from "@react-navigation/native";
2import { useEffect, useLayoutEffect, useState } from "react";
3import { AppState } from "react-native";
4
5/**
6 * Wall clock for elapsed-time labels, advanced once a second only while
7 * `enabled`, the screen is focused, and the app is active. Retained routes stay
8 * mounted when hidden, so an ungated interval would keep re-rendering them.
9 * The clock re-reads the time before paint when ticking resumes, so a label
10 * never shows the moment it paused.
11 */
12export function useVisibleSecondClock(enabled: boolean): number {
13 const focused = useIsFocused();
14 const [appActive, setAppActive] = useState(() => AppState.currentState === "active");
15 const [nowMs, setNowMs] = useState(() => Date.now());
16 useEffect(() => {
17 const subscription = AppState.addEventListener("change", (state) =>
18 setAppActive(state === "active"),
19 );
20 // A change between the first render and this subscription has no event to replay.
21 // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed.
22 setAppActive(AppState.currentState === "active");
23 return () => subscription.remove();
24 }, []);
25 const ticking = enabled && focused && appActive;
26 useLayoutEffect(() => {
27 if (!ticking) return;
28 // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still.
29 setNowMs(Date.now());
30 const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
31 return () => clearInterval(intervalId);
32 }, [ticking]);
33 return nowMs;
34}
pnpm-lock.yaml+3
......@@ -512,6 +512,9 @@ importers:
512512 babel-preset-expo:
513513 specifier: ~58.0.8
514514 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)
515 jsdom:
516 specifier: ^30.0.1
517 version: 30.1.0(@noble/hashes@1.8.0)
515518 tailwindcss:
516519 specifier: 4.3.3
517520 version: 4.3.3