Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 10 additions & 13 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ import {
type FoundationHotModule,
} from "./lib/foundation-fast-refresh";
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
import { restoreSettingsLinkState } from "./features/settings/settings-navigation";
import {
Expand Down Expand Up @@ -99,6 +100,14 @@ function handleNavigationReady(): void {
declare const module: { readonly hot?: FoundationHotModule } | undefined;

const appServiceDisposers = [
installUiRuntimeMemoryWarningGc(),
subscribeComposerDraftModelReconciliation({
registry: appAtomRegistry,
threadShellAtom: environmentThreadShells.threadShellAtom,
threadRefsAtom: environmentThreadShells.threadRefsAtom,
shellStateAtom: environmentShell.stateAtom,
}),
installSubscriptionUsageRefresh(),
installPermissionUpdateNotice(),
installThreadOutboxDelivery(),
installComposerAttachmentUploads(),
Expand All @@ -118,18 +127,6 @@ function SplashScreenCoordinator() {
}

export default function App() {
useUiRuntimeMemoryWarningGc();
useEffect(
() =>
subscribeComposerDraftModelReconciliation({
registry: appAtomRegistry,
threadShellAtom: environmentThreadShells.threadShellAtom,
threadRefsAtom: environmentThreadShells.threadRefsAtom,
shellStateAtom: environmentShell.stateAtom,
}),
[],
);

return (
<RegistryContext.Provider value={appAtomRegistry}>
<CloudAuthProvider>
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/lib/appState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { AccessibilityInfo, AppState } from "react-native";

type Listener = () => void;

function subscribeAppState(onChange: Listener): () => void {
export function subscribeAppState(onChange: Listener): () => void {
const subscription = AppState.addEventListener("change", () => onChange());
return () => subscription.remove();
}

function isAppActive(): boolean {
export function isAppActive(): boolean {
return AppState.currentState === "active";
}

Expand Down
13 changes: 5 additions & 8 deletions apps/mobile/src/lib/useUiRuntimeMemoryWarningGc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useEffect } from "react";
import { AppState } from "react-native";
import { scheduleOnUI } from "react-native-worklets";

Expand All @@ -9,11 +8,9 @@ function collectUiRuntimeGarbage() {
}

/** iOS memory warnings collect only RN's heap; collect worklets too to release shadow nodes and serializables. */
export function useUiRuntimeMemoryWarningGc() {
useEffect(() => {
const subscription = AppState.addEventListener("memoryWarning", () => {
scheduleOnUI(collectUiRuntimeGarbage);
});
return () => subscription.remove();
}, []);
export function installUiRuntimeMemoryWarningGc(): () => void {
const subscription = AppState.addEventListener("memoryWarning", () => {
scheduleOnUI(collectUiRuntimeGarbage);
});
return () => subscription.remove();
}
2 changes: 0 additions & 2 deletions apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear
import { environmentCatalog } from "../connection/catalog";
import { environmentPresentations } from "../state/presentation";
import { publishSubscriptionUsage } from "./publishSubscriptionUsage";
import { useSubscriptionUsage } from "./useSubscriptionUsage";
import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot";
import { createWidgetTheme } from "./widgetTheme";

Expand All @@ -29,7 +28,6 @@ export function SubscriptionUsageCoordinator() {
} = useAppearancePreferences();
const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
const snapshot = useAtomValue(snapshotAtom);
useSubscriptionUsage(catalog.isReady);
const androidAppearance = Platform.OS === "android" ? themeAppearance : null;
useEffect(() => {
if (!catalog.isReady || !appearanceReady) return;
Expand Down
97 changes: 97 additions & 0 deletions apps/mobile/src/widgets/useSubscriptionUsage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { EnvironmentId } from "@t3tools/contracts";
import { Atom, AtomRegistry } from "effect/reactivity";
import { describe, expect, it, vi } from "vite-plus/test";

const fixture = vi.hoisted(() => ({
appState: "active",
appStateListeners: new Set<() => void>(),
refreshed: [] as string[],
}));

vi.mock("react-native", () => ({
AppState: {
get currentState() {
return fixture.appState;
},
addEventListener: (_event: string, listener: () => void) => {
fixture.appStateListeners.add(listener);
return { remove: () => fixture.appStateListeners.delete(listener) };
},
},
AccessibilityInfo: { addEventListener: vi.fn(), isReduceMotionEnabled: vi.fn() },
}));

vi.mock("../state/atom-registry", async () => {
const { AtomRegistry } = await import("effect/reactivity");
return { appAtomRegistry: AtomRegistry.make() };
});

vi.mock("../connection/catalog", async () => {
const { Atom } = await import("effect/reactivity");
return { environmentCatalog: { catalogValueAtom: Atom.make({ isReady: false }) } };
});

vi.mock("../state/presentation", async () => {
const { Atom } = await import("effect/reactivity");
return {
environmentPresentations: { presentationsAtom: Atom.make(new Map<string, unknown>()) },
};
});

vi.mock("../state/server", () => ({
serverEnvironment: { refreshProviders: { label: "Refresh providers" } },
}));

vi.mock("@t3tools/client-runtime/state/runtime", () => ({
runAtomCommand: (
_registry: unknown,
_command: unknown,
input: { readonly environmentId: string },
) => {
fixture.refreshed.push(input.environmentId);
return Promise.resolve();
},
}));

import { environmentCatalog } from "../connection/catalog";
import { appAtomRegistry } from "../state/atom-registry";
import { environmentPresentations } from "../state/presentation";
import { installSubscriptionUsageRefresh } from "./useSubscriptionUsage";

const registry = appAtomRegistry as AtomRegistry.AtomRegistry;
const catalogAtom = environmentCatalog.catalogValueAtom as unknown as Atom.Writable<{
isReady: boolean;
}>;
const presentationsAtom = environmentPresentations.presentationsAtom as unknown as Atom.Writable<
Map<string, { connection: { phase: string } }>
>;

const connected = EnvironmentId.make("env-connected");
const connecting = EnvironmentId.make("env-connecting");

describe("installSubscriptionUsageRefresh", () => {
it("refreshes connected environments once the catalog is ready and the app is active", () => {
fixture.appState = "background";
fixture.refreshed = [];
registry.set(catalogAtom, { isReady: false });
registry.set(
presentationsAtom,
new Map([
[connected, { connection: { phase: "connected" } }],
[connecting, { connection: { phase: "connecting" } }],
]),
);

const dispose = installSubscriptionUsageRefresh();
registry.set(catalogAtom, { isReady: true });
// Backgrounded: nothing refreshes yet.
expect(fixture.refreshed).toEqual([]);

fixture.appState = "active";
for (const listener of fixture.appStateListeners) listener();
expect(fixture.refreshed).toEqual([connected]);

dispose();
expect(fixture.appStateListeners.size).toBe(0);
});
});
71 changes: 39 additions & 32 deletions apps/mobile/src/widgets/useSubscriptionUsage.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,47 @@
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useMemo } from "react";
import { AppState } from "react-native";
import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";

import { environmentCatalog } from "../connection/catalog";
import { isAppActive, subscribeAppState } from "../lib/appState";
import { appAtomRegistry } from "../state/atom-registry";
import { environmentPresentations } from "../state/presentation";
import { serverEnvironment } from "../state/server";
import { useAtomCommand } from "../state/use-atom-command";
import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot";

export function useSubscriptionUsage(enabled = true) {
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
const refresh = useMemo(
() =>
createWidgetRefresher(
(environmentId: Parameters<typeof refreshProviders>[0]["environmentId"]) =>
refreshProviders({ environmentId, input: {} }),
),
[refreshProviders],
/**
* App-lifetime worker that keeps provider quotas fresh for the usage widget:
* on resume, on every connection change, and on a fixed interval while active.
* Each environment is refreshed at most once per interval.
*/
export function installSubscriptionUsageRefresh(): () => void {
const refresh = createWidgetRefresher((environmentId: EnvironmentId) =>
runAtomCommand(
appAtomRegistry,
serverEnvironment.refreshProviders,
{ environmentId, input: {} },
{ label: serverEnvironment.refreshProviders.label, reportFailure: false },
),
);
const update = () => {
if (!isAppActive() || !appAtomRegistry.get(environmentCatalog.catalogValueAtom).isReady) return;
const connected = [...appAtomRegistry.get(environmentPresentations.presentationsAtom)]
.filter(([, presentation]) => presentation.connection.phase === "connected")
.map(([id]) => id);
void refresh(connected, Date.now());
};

useEffect(() => {
const update = () => {
if (!enabled || AppState.currentState !== "active") return;
const connected = [...presentations]
.filter(([, presentation]) => presentation.connection.phase === "connected")
.map(([id]) => id);
void refresh(connected, Date.now());
};
update();
const subscription = AppState.addEventListener("change", update);
const timer = setInterval(update, WIDGET_REFRESH_INTERVAL);
return () => {
subscription.remove();
clearInterval(timer);
};
}, [enabled, presentations, refresh]);
const unsubscribeCatalog = appAtomRegistry.subscribe(environmentCatalog.catalogValueAtom, update);
const unsubscribePresentations = appAtomRegistry.subscribe(
environmentPresentations.presentationsAtom,
update,
{ immediate: true },
);
const unsubscribeAppState = subscribeAppState(update);
const timer = setInterval(update, WIDGET_REFRESH_INTERVAL);
return () => {
unsubscribeCatalog();
unsubscribePresentations();
unsubscribeAppState();
clearInterval(timer);
};
}
Loading