Skip to content

Commit 5811d5c

Browse files
refactor(mobile): app-lifetime services install once, and the usage widget stops re-rendering on every presentation change (#25740)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3425671 commit 5811d5c

6 files changed

Lines changed: 153 additions & 57 deletions

File tree

‎apps/mobile/src/App.tsx‎

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ import {
4040
type FoundationHotModule,
4141
} from "./lib/foundation-fast-refresh";
4242
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
43-
import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
43+
import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
4444
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
45+
import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage";
4546
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
4647
import { restoreSettingsLinkState } from "./features/settings/settings-navigation";
4748
import {
@@ -99,6 +100,14 @@ function handleNavigationReady(): void {
99100
declare const module: { readonly hot?: FoundationHotModule } | undefined;
100101

101102
const appServiceDisposers = [
103+
installUiRuntimeMemoryWarningGc(),
104+
subscribeComposerDraftModelReconciliation({
105+
registry: appAtomRegistry,
106+
threadShellAtom: environmentThreadShells.threadShellAtom,
107+
threadRefsAtom: environmentThreadShells.threadRefsAtom,
108+
shellStateAtom: environmentShell.stateAtom,
109+
}),
110+
installSubscriptionUsageRefresh(),
102111
installPermissionUpdateNotice(),
103112
installThreadOutboxDelivery(),
104113
installComposerAttachmentUploads(),
@@ -118,18 +127,6 @@ function SplashScreenCoordinator() {
118127
}
119128

120129
export default function App() {
121-
useUiRuntimeMemoryWarningGc();
122-
useEffect(
123-
() =>
124-
subscribeComposerDraftModelReconciliation({
125-
registry: appAtomRegistry,
126-
threadShellAtom: environmentThreadShells.threadShellAtom,
127-
threadRefsAtom: environmentThreadShells.threadRefsAtom,
128-
shellStateAtom: environmentShell.stateAtom,
129-
}),
130-
[],
131-
);
132-
133130
return (
134131
<RegistryContext.Provider value={appAtomRegistry}>
135132
<CloudAuthProvider>

‎apps/mobile/src/lib/appState.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { AccessibilityInfo, AppState } from "react-native";
33

44
type Listener = () => void;
55

6-
function subscribeAppState(onChange: Listener): () => void {
6+
export function subscribeAppState(onChange: Listener): () => void {
77
const subscription = AppState.addEventListener("change", () => onChange());
88
return () => subscription.remove();
99
}
1010

11-
function isAppActive(): boolean {
11+
export function isAppActive(): boolean {
1212
return AppState.currentState === "active";
1313
}
1414

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useEffect } from "react";
21
import { AppState } from "react-native";
32
import { scheduleOnUI } from "react-native-worklets";
43

@@ -9,11 +8,9 @@ function collectUiRuntimeGarbage() {
98
}
109

1110
/** iOS memory warnings collect only RN's heap; collect worklets too to release shadow nodes and serializables. */
12-
export function useUiRuntimeMemoryWarningGc() {
13-
useEffect(() => {
14-
const subscription = AppState.addEventListener("memoryWarning", () => {
15-
scheduleOnUI(collectUiRuntimeGarbage);
16-
});
17-
return () => subscription.remove();
18-
}, []);
11+
export function installUiRuntimeMemoryWarningGc(): () => void {
12+
const subscription = AppState.addEventListener("memoryWarning", () => {
13+
scheduleOnUI(collectUiRuntimeGarbage);
14+
});
15+
return () => subscription.remove();
1916
}

‎apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear
77
import { environmentCatalog } from "../connection/catalog";
88
import { environmentPresentations } from "../state/presentation";
99
import { publishSubscriptionUsage } from "./publishSubscriptionUsage";
10-
import { useSubscriptionUsage } from "./useSubscriptionUsage";
1110
import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";
1211
import { createWidgetTheme } from "./widgetTheme";
1312

@@ -29,7 +28,6 @@ export function SubscriptionUsageCoordinator() {
2928
} = useAppearancePreferences();
3029
const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
3130
const snapshot = useAtomValue(snapshotAtom);
32-
useSubscriptionUsage(catalog.isReady);
3331
const androidAppearance = Platform.OS === "android" ? themeAppearance : null;
3432
useEffect(() => {
3533
if (!catalog.isReady || !appearanceReady) return;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { EnvironmentId } from "@t3tools/contracts";
2+
import { Atom, AtomRegistry } from "effect/reactivity";
3+
import { describe, expect, it, vi } from "vite-plus/test";
4+
5+
const fixture = vi.hoisted(() => ({
6+
appState: "active",
7+
appStateListeners: new Set<() => void>(),
8+
refreshed: [] as string[],
9+
}));
10+
11+
vi.mock("react-native", () => ({
12+
AppState: {
13+
get currentState() {
14+
return fixture.appState;
15+
},
16+
addEventListener: (_event: string, listener: () => void) => {
17+
fixture.appStateListeners.add(listener);
18+
return { remove: () => fixture.appStateListeners.delete(listener) };
19+
},
20+
},
21+
AccessibilityInfo: { addEventListener: vi.fn(), isReduceMotionEnabled: vi.fn() },
22+
}));
23+
24+
vi.mock("../state/atom-registry", async () => {
25+
const { AtomRegistry } = await import("effect/reactivity");
26+
return { appAtomRegistry: AtomRegistry.make() };
27+
});
28+
29+
vi.mock("../connection/catalog", async () => {
30+
const { Atom } = await import("effect/reactivity");
31+
return { environmentCatalog: { catalogValueAtom: Atom.make({ isReady: false }) } };
32+
});
33+
34+
vi.mock("../state/presentation", async () => {
35+
const { Atom } = await import("effect/reactivity");
36+
return {
37+
environmentPresentations: { presentationsAtom: Atom.make(new Map<string, unknown>()) },
38+
};
39+
});
40+
41+
vi.mock("../state/server", () => ({
42+
serverEnvironment: { refreshProviders: { label: "Refresh providers" } },
43+
}));
44+
45+
vi.mock("@t3tools/client-runtime/state/runtime", () => ({
46+
runAtomCommand: (
47+
_registry: unknown,
48+
_command: unknown,
49+
input: { readonly environmentId: string },
50+
) => {
51+
fixture.refreshed.push(input.environmentId);
52+
return Promise.resolve();
53+
},
54+
}));
55+
56+
import { environmentCatalog } from "../connection/catalog";
57+
import { appAtomRegistry } from "../state/atom-registry";
58+
import { environmentPresentations } from "../state/presentation";
59+
import { installSubscriptionUsageRefresh } from "./useSubscriptionUsage";
60+
61+
const registry = appAtomRegistry as AtomRegistry.AtomRegistry;
62+
const catalogAtom = environmentCatalog.catalogValueAtom as unknown as Atom.Writable<{
63+
isReady: boolean;
64+
}>;
65+
const presentationsAtom = environmentPresentations.presentationsAtom as unknown as Atom.Writable<
66+
Map<string, { connection: { phase: string } }>
67+
>;
68+
69+
const connected = EnvironmentId.make("env-connected");
70+
const connecting = EnvironmentId.make("env-connecting");
71+
72+
describe("installSubscriptionUsageRefresh", () => {
73+
it("refreshes connected environments once the catalog is ready and the app is active", () => {
74+
fixture.appState = "background";
75+
fixture.refreshed = [];
76+
registry.set(catalogAtom, { isReady: false });
77+
registry.set(
78+
presentationsAtom,
79+
new Map([
80+
[connected, { connection: { phase: "connected" } }],
81+
[connecting, { connection: { phase: "connecting" } }],
82+
]),
83+
);
84+
85+
const dispose = installSubscriptionUsageRefresh();
86+
registry.set(catalogAtom, { isReady: true });
87+
// Backgrounded: nothing refreshes yet.
88+
expect(fixture.refreshed).toEqual([]);
89+
90+
fixture.appState = "active";
91+
for (const listener of fixture.appStateListeners) listener();
92+
expect(fixture.refreshed).toEqual([connected]);
93+
94+
dispose();
95+
expect(fixture.appStateListeners.size).toBe(0);
96+
});
97+
});
Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,47 @@
1-
import { useAtomValue } from "@effect/atom-react";
2-
import { useEffect, useMemo } from "react";
3-
import { AppState } from "react-native";
1+
import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
2+
import type { EnvironmentId } from "@t3tools/contracts";
43

4+
import { environmentCatalog } from "../connection/catalog";
5+
import { isAppActive, subscribeAppState } from "../lib/appState";
6+
import { appAtomRegistry } from "../state/atom-registry";
57
import { environmentPresentations } from "../state/presentation";
68
import { serverEnvironment } from "../state/server";
7-
import { useAtomCommand } from "../state/use-atom-command";
89
import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot";
910

10-
export function useSubscriptionUsage(enabled = true) {
11-
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
12-
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
13-
reportFailure: false,
14-
});
15-
const refresh = useMemo(
16-
() =>
17-
createWidgetRefresher(
18-
(environmentId: Parameters<typeof refreshProviders>[0]["environmentId"]) =>
19-
refreshProviders({ environmentId, input: {} }),
20-
),
21-
[refreshProviders],
11+
/**
12+
* App-lifetime worker that keeps provider quotas fresh for the usage widget:
13+
* on resume, on every connection change, and on a fixed interval while active.
14+
* Each environment is refreshed at most once per interval.
15+
*/
16+
export function installSubscriptionUsageRefresh(): () => void {
17+
const refresh = createWidgetRefresher((environmentId: EnvironmentId) =>
18+
runAtomCommand(
19+
appAtomRegistry,
20+
serverEnvironment.refreshProviders,
21+
{ environmentId, input: {} },
22+
{ label: serverEnvironment.refreshProviders.label, reportFailure: false },
23+
),
2224
);
25+
const update = () => {
26+
if (!isAppActive() || !appAtomRegistry.get(environmentCatalog.catalogValueAtom).isReady) return;
27+
const connected = [...appAtomRegistry.get(environmentPresentations.presentationsAtom)]
28+
.filter(([, presentation]) => presentation.connection.phase === "connected")
29+
.map(([id]) => id);
30+
void refresh(connected, Date.now());
31+
};
2332

24-
useEffect(() => {
25-
const update = () => {
26-
if (!enabled || AppState.currentState !== "active") return;
27-
const connected = [...presentations]
28-
.filter(([, presentation]) => presentation.connection.phase === "connected")
29-
.map(([id]) => id);
30-
void refresh(connected, Date.now());
31-
};
32-
update();
33-
const subscription = AppState.addEventListener("change", update);
34-
const timer = setInterval(update, WIDGET_REFRESH_INTERVAL);
35-
return () => {
36-
subscription.remove();
37-
clearInterval(timer);
38-
};
39-
}, [enabled, presentations, refresh]);
33+
const unsubscribeCatalog = appAtomRegistry.subscribe(environmentCatalog.catalogValueAtom, update);
34+
const unsubscribePresentations = appAtomRegistry.subscribe(
35+
environmentPresentations.presentationsAtom,
36+
update,
37+
{ immediate: true },
38+
);
39+
const unsubscribeAppState = subscribeAppState(update);
40+
const timer = setInterval(update, WIDGET_REFRESH_INTERVAL);
41+
return () => {
42+
unsubscribeCatalog();
43+
unsubscribePresentations();
44+
unsubscribeAppState();
45+
clearInterval(timer);
46+
};
4047
}

0 commit comments

Comments
 (0)