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
117 changes: 117 additions & 0 deletions apps/mobile/src/features/usage/UsageLimitsSection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { EnvironmentId } from "@t3tools/contracts";
import { beforeEach, expect, it, vi } from "vite-plus/test";

const state = vi.hoisted(() => ({
values: [] as unknown[],
cursor: 0,
presentations: new Map(),
refreshProviders: vi.fn(),
autoRefresh: async () => {},
refreshingRef: { current: false },
}));
vi.mock("react", () => ({
useState: (initial: unknown) => {
const index = state.cursor++;
if (!(index in state.values)) {
state.values[index] = typeof initial === "function" ? initial() : initial;
}
return [
state.values[index],
(next: unknown) => {
state.values[index] = typeof next === "function" ? next(state.values[index]) : next;
},
];
},
useRef: () => state.refreshingRef,
useEffect: () => {},
useEffectEvent: (callback: () => Promise<void>) => {
state.autoRefresh = callback;
return callback;
},
}));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));
vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" }));
vi.mock("../../components/AppText", () => ({ AppText: "span" }));
vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null }));
vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) }));
vi.mock("../../state/presentation", () => ({
environmentPresentations: { presentationsAtom: null },
}));
vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } }));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders }));

import { useRefreshLimits } from "./UsageLimitsSection";
import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";

beforeEach(() => {
state.values = [];
state.cursor = 0;
state.refreshingRef.current = false;
state.refreshProviders.mockReset();
});

it("keeps a newer environment failure when an older refresh finishes", async () => {
const a = EnvironmentId.make("mobile-limits-a");
const b = EnvironmentId.make("mobile-limits-b");
const pending = Promise.withResolvers<{ _tag: string }>();
const read = () => {
state.cursor = 0;
return useRefreshLimits();
};
const presentation = (label: string) => ({
connection: { phase: "connected" },
entry: { target: { label } },
});
state.presentations = new Map([[a, presentation("A")]]);
state.refreshProviders.mockImplementation(({ environmentId }) =>
environmentId === a ? pending.promise : Promise.resolve({ _tag: "Failure" }),
);
const first = read().refresh();
state.presentations = new Map([
[a, presentation("A")],
[b, presentation("B")],
]);
read();
await state.autoRefresh();
expect(read().failedLabels).toEqual(["B"]);
pending.resolve({ _tag: "Success" });
await first;
expect(read().failedLabels).toEqual(["B"]);
});

it("does not let an older multi-environment batch clear a newer failure for the same environment", async () => {
const a = EnvironmentId.make("mobile-limits-race-a");
const b = EnvironmentId.make("mobile-limits-race-b");
const aFirst = Promise.withResolvers<{ _tag: string }>();
const bFirst = Promise.withResolvers<{ _tag: string }>();
const read = (selected: ReadonlySet<EnvironmentId> | null = null) => {
state.cursor = 0;
return useRefreshLimits(selected);
};
const presentation = (label: string) => ({
connection: { phase: "connected" },
entry: { target: { label } },
});
state.presentations = new Map([
[a, presentation("A")],
[b, presentation("B")],
]);
let aCalls = 0;
state.refreshProviders.mockImplementation(({ environmentId }) =>
environmentId === b
? bFirst.promise
: ++aCalls === 1
? aFirst.promise
: Promise.resolve({ _tag: "Failure" }),
);
read();
const older = state.autoRefresh();
aFirst.resolve({ _tag: "Success" });
await refreshUsageLimits(a, () => aFirst.promise);
const selected = new Set([a]);
await read(selected).refresh();
expect(read(selected).failedLabels).toEqual(["A"]);
bFirst.resolve({ _tag: "Success" });
await older;
expect(read(selected).failedLabels).toEqual(["A"]);
});
65 changes: 49 additions & 16 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
paceOf,
remainingPercent,
} from "@t3tools/shared/usageLimits";
import { type ReactNode, useState } from "react";
import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
import { Alert, Pressable, View } from "react-native";

import { AppText as Text } from "../../components/AppText";
Expand Down Expand Up @@ -279,47 +280,79 @@ export function ResetCredits(props: {
* Environments whose probe failed are named, since their rows keep showing
* the previous quota with nothing else to say so.
*/
export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null) {
export function useRefreshLimits(
selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
active = false,
) {
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
const [now, setNow] = useState(() => Date.now());
const [refreshing, setRefreshing] = useState(false);
const refreshingRef = useRef(false);
const [failedEnvironments, setFailedEnvironments] = useState<
readonly { environmentId: EnvironmentId; label: string }[]
>([]);
// Always toggles `refreshing`, even with nothing to probe: Android's
// RefreshControl keeps its spinner up until it sees true then false.
const refresh = async () => {
const refresh = async (automatic = false) => {
const connected = [...presentations].filter(
([environmentId, presentation]) =>
presentation.connection.phase === "connected" &&
(selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
);
setRefreshing(true);
try {
const results = await Promise.all(
connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })),
);
setFailedEnvironments(
connected
.filter((_, index) => results[index]?._tag === "Failure")
.map(([environmentId, presentation]) => ({
await Promise.all(
connected.map(async ([environmentId, presentation]) => {
const result = await refreshUsageLimits(
environmentId,
label: presentation.entry.target.label,
})),
() => refreshProviders({ environmentId, input: {} }),
automatic,
);
if (result === undefined) return;
setFailedEnvironments((previous) => [
...previous.filter((failed) => failed.environmentId !== environmentId),
...(result._tag === "Failure"
? [{ environmentId, label: presentation.entry.target.label }]
: []),
]);
}),
);
} finally {
setNow(Date.now());
}
};
// Always toggles `refreshing`, even with nothing to probe: Android's
// RefreshControl keeps its spinner up until it sees true then false.
const refreshManually = async () => {
if (refreshingRef.current) return;
refreshingRef.current = true;
setRefreshing(true);
try {
await refresh();
} finally {
refreshingRef.current = false;
setRefreshing(false);
}
};
const connectedLimitsEnvironments = [...presentations]
.filter(
([environmentId, presentation]) =>
presentation.connection.phase === "connected" &&
(selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)),
)
.map(([environmentId]) => environmentId)
.sort()
.join(",");
const autoRefreshLimits = useEffectEvent(() => refresh(true));
useEffect(() => {
if (active && connectedLimitsEnvironments) void autoRefreshLimits();
}, [active, connectedLimitsEnvironments]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const failedLabels = failedEnvironments
.filter(
({ environmentId }) =>
selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),
)
.map(({ label }) => label);
return { now, refreshing, failedLabels, refresh };
return { now, refreshing, failedLabels, refresh: refreshManually };
}
5 changes: 3 additions & 2 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native";
import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
import {
isCompatibleUsageContractVersion,
isModelCostUnknown,
Expand Down Expand Up @@ -95,7 +95,8 @@ export function UsageRouteScreen() {
window,
selectedEnvironmentIds,
);
const limits = useRefreshLimits(selectedEnvironmentIds);
const isFocused = useIsFocused();
const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");

const days = useMemo(
() => enumerateDays(window.sinceDay, window.untilDay),
Expand Down
101 changes: 97 additions & 4 deletions apps/web/src/components/usage/UsagePage.refresh.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
import { mergeUsage } from "@t3tools/shared/usageMerge";
import { act } from "react";
import { StrictMode, act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

const state = vi.hoisted(() => ({
presentations: new Map(),
refreshProviders: vi.fn(async () => undefined),
metric: "limits",
}));
vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations }));
vi.mock("../../state/presentation", () => ({
Expand Down Expand Up @@ -43,7 +44,7 @@ vi.mock("../../state/usage", () => ({
}),
}));
vi.mock("./usagePagePreferences", () => ({
readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }),
readUsagePagePreferences: () => ({ metric: state.metric, windowDays: 30 }),
saveUsagePagePreferences: vi.fn(),
}));
vi.mock("../ui/button", () => ({ Button: "button" }));
Expand Down Expand Up @@ -83,13 +84,16 @@ vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ lab
import { UsagePage } from "./UsagePage";

let renderer: ReactTestRenderer;
let environmentNumber = 0;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z"));
environmentNumber += 1;
state.metric = "limits";
state.refreshProviders.mockClear();
state.presentations = new Map([
[
EnvironmentId.make("test"),
EnvironmentId.make(`test-${environmentNumber}`),
{
entry: { target: { label: "Test" } },
connection: { phase: "connected" },
Expand Down Expand Up @@ -150,7 +154,10 @@ it.each([0, 1])(
.at(buttonIndex)!
.props.onClick();
});
expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} });
expect(state.refreshProviders).toHaveBeenCalledWith({
environmentId: `test-${environmentNumber}`,
input: {},
});
expect(
JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
).toContain("in 1h 30m");
Expand All @@ -175,5 +182,91 @@ it("uses the current time when returning to limits from tokens", async () => {
expect(
JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)),
).toContain("in 1h 0m");
expect(state.refreshProviders).toHaveBeenCalledTimes(2);
});

it("refreshes once on opening Limits and suppresses rapid returns and remounts", async () => {
state.metric = "tokens";
await act(() => {
renderer = create(
<StrictMode>
<UsagePage />
</StrictMode>,
);
});
expect(state.refreshProviders).not.toHaveBeenCalled();
const selectMetric = (metric: string) =>
renderer.root
.findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]!
.props.onValueChange([metric]);
await act(() => selectMetric("limits"));
expect(state.refreshProviders).toHaveBeenCalledTimes(1);
await act(() => selectMetric("tokens"));
await act(() => selectMetric("limits"));
await act(() => renderer.unmount());
state.metric = "limits";
await act(() => {
renderer = create(
<StrictMode>
<UsagePage />
</StrictMode>,
);
});
expect(state.refreshProviders).toHaveBeenCalledTimes(1);
await act(() => selectMetric("tokens"));
vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:05:00Z"));
await act(() => selectMetric("limits"));
expect(state.refreshProviders).toHaveBeenCalledTimes(2);
});

it("waits for connection and refreshes new environments during a slow refresh", async () => {
const [id, presentation] = [...state.presentations][0]!;
state.presentations = new Map([[id, { ...presentation, connection: { phase: "disconnected" } }]]);
await act(() => {
renderer = create(<UsagePage />);
});
expect(state.refreshProviders).not.toHaveBeenCalled();
let finishRefresh!: () => void;
state.refreshProviders.mockImplementationOnce(
() =>
new Promise<undefined>((resolve) => {
finishRefresh = () => resolve(undefined);
}),
);
state.presentations = new Map([[id, presentation]]);
await act(() => renderer.update(<UsagePage />));
expect(state.refreshProviders).toHaveBeenCalledTimes(1);
const nextId = EnvironmentId.make(`${id}-next`);
state.presentations = new Map([...state.presentations, [nextId, presentation]]);
await act(() => renderer.update(<UsagePage />));
expect(state.refreshProviders).toHaveBeenCalledTimes(2);
expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} });
await act(() => finishRefresh());
});

it("keeps manual refresh busy until the already-running automatic check settles", async () => {
let finishRefresh!: () => void;
const pending = new Promise<undefined>((resolve) => {
finishRefresh = () => resolve(undefined);
});
state.refreshProviders.mockImplementationOnce(() => pending);
await act(() => {
renderer = create(<UsagePage />);
});
const button = () =>
renderer.root.findAll(
(node) => node.type === "button" && node.props["aria-label"] === "Refresh limits",
)[0]!;
expect(state.refreshProviders).toHaveBeenCalledTimes(1);
await act(() => button().props.onClick());
try {
expect(button().props["aria-busy"]).toBe(true);
expect(state.refreshProviders).toHaveBeenCalledTimes(1);
} finally {
await act(async () => {
finishRefresh();
await pending;
});
}
expect(button().props["aria-busy"]).toBe(false);
});
Loading
Loading