From 0e196d1e70da7bd2171d049f181688f45f555180 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:30:09 +1100 Subject: [PATCH 1/3] perf(mobile): skip unchanged working duration renders --- .../features/threads/thread-list-v2-items.tsx | 12 +- ...se-visible-working-duration-label.test.tsx | 238 ++++++++++++++++++ .../use-visible-working-duration-label.ts | 43 ++++ apps/mobile/vite.config.ts | 1 + 4 files changed, 285 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx create mode 100644 apps/mobile/src/features/threads/use-visible-working-duration-label.ts diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 7018f99278986..bc2a750ec2518 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -14,17 +14,14 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { threadArrangementOpenAtom } from "../../state/thread-order"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -import { useVisibleSecondClock } from "./use-visible-second-clock"; +import { useVisibleWorkingDurationLabel } from "./use-visible-working-duration-label"; import type { ThreadMoveDestination } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { - formatWorkingDurationLabel, - resolveThreadWorkingStartedAt, -} from "@t3tools/client-runtime/state/models"; +import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; @@ -126,10 +123,7 @@ const DONE_STATUS_LABEL: StatusLabel = { }; function ThreadListV2Duration(props: { readonly startedAt: string | null }) { - const startedAtMs = Date.parse(props.startedAt ?? ""); - const nowMs = useVisibleSecondClock(Number.isFinite(startedAtMs)); - if (!Number.isFinite(startedAtMs)) return null; - return ` ${formatWorkingDurationLabel(nowMs - startedAtMs)}`; + return useVisibleWorkingDurationLabel(props.startedAt); } const CARD_MENU_ACTIONS: MenuAction[] = [ diff --git a/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx b/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx new file mode 100644 index 0000000000000..d6f899c80904e --- /dev/null +++ b/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx @@ -0,0 +1,238 @@ +import { + NavigationContext, + type NavigationProp, + type ParamListBase, +} from "@react-navigation/native"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const native = vi.hoisted(() => ({ + appState: { + currentState: "active", + listeners: new Set<(state: string) => void>(), + addEventListener(_event: string, listener: (state: string) => void) { + native.appState.listeners.add(listener); + return { remove: () => native.appState.listeners.delete(listener) }; + }, + }, +})); + +vi.mock("react-native", () => ({ AppState: native.appState })); +vi.mock("@react-navigation/native", async () => { + const { createRequire } = await import("node:module"); + const requireNavigation = createRequire( + createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), + ); + // Exercise the real focus subscription without loading native navigation views. + const { NavigationContext, useIsFocused } = requireNavigation("@react-navigation/core"); + return { NavigationContext, useIsFocused }; +}); + +import { useVisibleWorkingDurationLabel } from "./use-visible-working-duration-label"; + +const timestamp = (ms: number) => new Date(ms).toISOString(); + +function setup(startedAt: string | null, nowMs = 0, active = true, focusedInitially = true) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + vi.setSystemTime(nowMs); + native.appState.currentState = active ? "active" : "background"; + native.appState.listeners.clear(); + let focused = focusedInitially; + const focusListeners = new Set<() => void>(); + const navigation = { + isFocused: () => focused, + addListener: (_event: string, listener: () => void) => { + focusListeners.add(listener); + return () => focusListeners.delete(listener); + }, + } as unknown as NavigationProp; + let executions = 0; + function Probe(props: { readonly startedAt: string | null }) { + executions++; + return useVisibleWorkingDurationLabel(props.startedAt); + } + const host = document.createElement("div"); + const root = createRoot(host); + const render = (start: string | null) => + act(() => + root.render( + // oxlint-disable-next-line react/jsx-no-constructed-context-values -- One stable navigation fixture per mounted probe, constructed outside React render. + + + , + ), + ); + render(startedAt); + return { + label: () => host.textContent, + executions: () => executions, + rebind: render, + advance: (ms: number) => act(() => vi.advanceTimersByTime(ms)), + focus: (next: boolean) => + act(() => { + focused = next; + for (const listener of focusListeners) listener(); + }), + appState: (state: string) => + act(() => { + native.appState.currentState = state; + for (const listener of native.appState.listeners) listener(state); + }), + [Symbol.dispose]: () => { + act(() => root.unmount()); + expect(vi.getTimerCount()).toBe(0); + expect(native.appState.listeners.size).toBe(0); + expect(focusListeners.size).toBe(0); + vi.useRealTimers(); + }, + }; +} + +it("publishes only the two label changes across 120 individually delivered minute-age ticks", () => { + using screen = setup(timestamp(0), 180_000); + const initialExecutions = screen.executions(); + const labels = [screen.label()]; + for (let second = 1; second <= 120; second++) { + screen.advance(1_000); + labels.push(screen.label()); + } + expect(labels).toEqual([ + ...Array.from({ length: 60 }, () => " 3m"), + ...Array.from({ length: 60 }, () => " 4m"), + " 5m", + ]); + expect(screen.executions() - initialExecutions).toBe(2); + expect(vi.getTimerCount()).toBe(1); +}); + +it("keeps second labels and on-phase minute and hour boundaries", () => { + using screen = setup(timestamp(0), 58_000); + screen.advance(1_000); + expect(screen.label()).toBe(" 59s"); + screen.advance(999); + expect(screen.label()).toBe(" 59s"); + screen.advance(1); + expect(screen.label()).toBe(" 1m"); + screen.rebind(timestamp(-60_000)); + expect(screen.label()).toBe(" 2m"); + screen.rebind(timestamp(-3_539_000)); + expect(screen.label()).toBe(" 59m"); + screen.advance(1_000); + expect(screen.label()).toBe(" 1h 0m"); +}); + +it.each([ + [59_999, " 59s", " 1m"], + [119_999, " 1m", " 2m"], + [3_599_999, " 59m", " 1h 0m"], +] as const)("retains the original interval phase across age %i", (age, before, after) => { + using screen = setup(timestamp(0), age); + expect(screen.label()).toBe(before); + screen.advance(1); + expect(screen.label()).toBe(before); + screen.advance(999); + expect(screen.label()).toBe(after); +}); + +it("does not align an off-grid start or a rebound start to wall-clock ticks", () => { + using screen = setup(timestamp(500), 180_250); + expect(screen.label()).toBe(" 2m"); + screen.advance(250); + expect(screen.label()).toBe(" 2m"); + screen.advance(749); + expect(screen.label()).toBe(" 2m"); + screen.advance(1); + expect(screen.label()).toBe(" 3m"); + screen.advance(350); + screen.rebind(timestamp(121_500)); + // Rebinding uses the last clock sample (181250), not render time (181600). + expect(screen.label()).toBe(" 59s"); + screen.advance(649); + expect(screen.label()).toBe(" 59s"); + screen.advance(1); + expect(screen.label()).toBe(" 1m"); + expect(vi.getTimerCount()).toBe(1); +}); + +it.each([null, "", "invalid"])("does not tick for missing/invalid start %s", (start) => { + using screen = setup(start, 180_000); + expect(screen.label()).toBe(""); + expect(vi.getTimerCount()).toBe(0); + screen.advance(15_000); + screen.rebind(timestamp(0)); + expect(screen.label()).toBe(" 3m"); + expect(vi.getTimerCount()).toBe(1); + screen.rebind(start); + expect(screen.label()).toBe(""); + expect(vi.getTimerCount()).toBe(0); +}); + +it("clamps future starts without publishing equal zero labels", () => { + using screen = setup(timestamp(2_000)); + const initialExecutions = screen.executions(); + screen.advance(1_000); + screen.advance(1_000); + expect(screen.label()).toBe(" 0s"); + expect(screen.executions()).toBe(initialExecutions); + screen.advance(1_000); + expect(screen.label()).toBe(" 1s"); +}); + +it("pauses on blur, retains sampled time on rebinding, and refreshes immediately on focus", () => { + using screen = setup(timestamp(0), 59_250); + screen.advance(350); + screen.focus(false); + expect(vi.getTimerCount()).toBe(0); + const pausedExecutions = screen.executions(); + screen.advance(60_000); + expect(screen.label()).toBe(" 59s"); + expect(screen.executions()).toBe(pausedExecutions); + screen.rebind(timestamp(-1_000)); + expect(screen.label()).toBe(" 1m"); + screen.focus(true); + expect(screen.label()).toBe(" 2m"); + screen.rebind(timestamp(60_000)); + expect(screen.label()).toBe(" 59s"); + screen.advance(999); + expect(screen.label()).toBe(" 59s"); + screen.advance(1); + expect(screen.label()).toBe(" 1m"); +}); + +it.each(["inactive", "background"])( + "pauses while %s and refreshes immediately on active", + (state) => { + using screen = setup(timestamp(0), 59_250); + screen.appState(state); + expect(vi.getTimerCount()).toBe(0); + screen.advance(61_000); + expect(screen.label()).toBe(" 59s"); + screen.appState("active"); + expect(screen.label()).toBe(" 2m"); + expect(vi.getTimerCount()).toBe(1); + screen.focus(false); + screen.appState("background"); + screen.advance(60_000); + screen.appState("active"); + expect(vi.getTimerCount()).toBe(0); + expect(screen.label()).toBe(" 2m"); + screen.focus(true); + expect(screen.label()).toBe(" 3m"); + }, +); + +it.each([ + [false, true], + [true, false], +] as const)("starts paused when active=%s focused=%s", (active, focused) => { + using screen = setup(timestamp(0), 59_000, active, focused); + expect(screen.label()).toBe(" 59s"); + expect(vi.getTimerCount()).toBe(0); + screen.advance(61_000); + screen.appState("active"); + screen.focus(true); + expect(screen.label()).toBe(" 2m"); + expect(vi.getTimerCount()).toBe(1); +}); diff --git a/apps/mobile/src/features/threads/use-visible-working-duration-label.ts b/apps/mobile/src/features/threads/use-visible-working-duration-label.ts new file mode 100644 index 0000000000000..8f52e2f98dc93 --- /dev/null +++ b/apps/mobile/src/features/threads/use-visible-working-duration-label.ts @@ -0,0 +1,43 @@ +import { formatWorkingDurationLabel } from "@t3tools/client-runtime/state/models"; +import { useIsFocused } from "@react-navigation/native"; +import { useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useAppActive } from "../../lib/appState"; + +function durationLabel(nowMs: number, startedAtMs: number): string | null { + return Number.isFinite(startedAtMs) + ? ` ${formatWorkingDurationLabel(nowMs - startedAtMs)}` + : null; +} + +export function useVisibleWorkingDurationLabel(startedAt: string | null): string | null { + const startedAtMs = useMemo(() => Date.parse(startedAt ?? ""), [startedAt]); + const focused = useIsFocused(); + const appActive = useAppActive(); + const [mountedAtMs] = useState(() => Date.now()); + const [label, setLabel] = useState(() => durationLabel(mountedAtMs, startedAtMs)); + const sample = useRef({ nowMs: mountedAtMs, label }); + const ticking = Number.isFinite(startedAtMs) && focused && appActive; + + const publish = useEffectEvent((nowMs: number, startMs = startedAtMs) => { + const next = durationLabel(nowMs, startMs); + sample.current.nowMs = nowMs; + if (sample.current.label === next) return; + sample.current.label = next; + setLabel(next); + }); + + // oxlint-disable-next-line t3code/react-effect-budget -- Rebind the retained sample before paint without shifting the interval, including while hidden. + useLayoutEffect(() => { + publish(sample.current.nowMs, startedAtMs); + }, [startedAtMs]); + + // oxlint-disable-next-line t3code/react-effect-budget -- Own the visible interval and refresh the paused clock before paint on resume. + useLayoutEffect(() => { + if (!ticking) return; + publish(Date.now()); + const intervalId = setInterval(() => publish(Date.now()), 1_000); + return () => clearInterval(intervalId); + }, [ticking]); + + return label; +} diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 77fffb6cb6117..cf03602bf5a40 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/threads/use-visible-working-duration-label.test.tsx", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", "src/lib/filePreview.test.ts", "src/features/threads/thread-inspector-navigation.test.tsx", From d6ef4a812829b48d619341dad5d47a317ebf50f4 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:45:13 +1100 Subject: [PATCH 2/3] chore(mobile): remove unused duration test lint directive --- .../features/threads/use-visible-working-duration-label.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx b/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx index d6f899c80904e..616740b12d035 100644 --- a/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx +++ b/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx @@ -58,7 +58,6 @@ function setup(startedAt: string | null, nowMs = 0, active = true, focusedInitia const render = (start: string | null) => act(() => root.render( - // oxlint-disable-next-line react/jsx-no-constructed-context-values -- One stable navigation fixture per mounted probe, constructed outside React render. , From 761384e7f536214eaeeaee3f5db8b9fcf2a8b839 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:42:45 +1100 Subject: [PATCH 3/3] test(mobile): remove duplicate duration baseline comparison --- ...se-visible-working-duration-label.test.tsx | 39 +------------------ 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx b/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx index fab9709f5a684..042c7437f4ed2 100644 --- a/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx +++ b/apps/mobile/src/features/threads/use-visible-working-duration-label.test.tsx @@ -30,26 +30,10 @@ vi.mock("@react-navigation/native", async () => { }); import { useVisibleWorkingDurationLabel } from "./use-visible-working-duration-label"; -import { useVisibleSecondClock } from "./use-visible-second-clock"; -import { formatWorkingDurationLabel } from "@t3tools/client-runtime/state/models"; - -function useMainDurationLabel(startedAt: string | null) { - const startedAtMs = Date.parse(startedAt ?? ""); - const nowMs = useVisibleSecondClock(Number.isFinite(startedAtMs)); - return Number.isFinite(startedAtMs) - ? ` ${formatWorkingDurationLabel(nowMs - startedAtMs)}` - : null; -} const timestamp = (ms: number) => new Date(ms).toISOString(); -function setup( - startedAt: string | null, - nowMs = 0, - active = true, - focusedInitially = true, - baseline = false, -) { +function setup(startedAt: string | null, nowMs = 0, active = true, focusedInitially = true) { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; vi.useFakeTimers(); vi.setSystemTime(nowMs); @@ -66,10 +50,9 @@ function setup( } as unknown as NavigationProp; let executions = 0; const starts = [{ id: "first", value: startedAt }]; - const useLabel = baseline ? useMainDurationLabel : useVisibleWorkingDurationLabel; function Probe(props: { readonly startedAt: string | null }) { executions++; - return useLabel(props.startedAt); + return useVisibleWorkingDurationLabel(props.startedAt); } const host = document.createElement("div"); const root = createRoot(host); @@ -280,21 +263,3 @@ it.each([ expect(screen.label()).toBe(" 2m"); expect(vi.getTimerCount()).toBe(1); }); - -it("keeps current-main labels while reducing 120 clock renders to two label renders", () => { - const samples: string[][] = []; - const renders: number[] = []; - for (const baseline of [true, false]) { - using screen = setup(timestamp(0), 180_000, true, true, baseline); - const initial = screen.executions(); - const labels = [screen.label()]; - for (let second = 0; second < 120; second++) { - screen.advance(1_000); - labels.push(screen.label()); - } - samples.push(labels); - renders.push(screen.executions() - initial); - } - expect(samples[1]).toEqual(samples[0]); - expect(renders).toEqual([120, 2]); -});