From 5b3b90543356e88ed4d081e53fd45e27aa79d82e Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:31:54 +0200 Subject: [PATCH 1/3] fix(usage): refresh limits when the tab opens --- .../src/features/usage/UsageLimitsSection.tsx | 60 ++++++++++++--- .../src/features/usage/UsageRouteScreen.tsx | 5 +- .../usage/UsagePage.refresh.test.tsx | 74 ++++++++++++++++++- apps/web/src/components/usage/UsagePage.tsx | 48 +++++++++--- docs/user/usage.md | 4 +- .../client-runtime/src/state/usage.test.ts | 40 +++++++++- packages/client-runtime/src/state/usage.ts | 19 +++++ 7 files changed, 221 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index e7afe114a566..56ed9c79c07b 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -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"; @@ -279,32 +280,43 @@ 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 | null = null) { +export function useRefreshLimits( + selectedEnvironmentIds: ReadonlySet | 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: {} })), + connected.map(([environmentId]) => + refreshUsageLimits( + environmentId, + () => refreshProviders({ environmentId, input: {} }), + automatic, + ), + ), ); - setFailedEnvironments( + setFailedEnvironments((previous) => connected - .filter((_, index) => results[index]?._tag === "Failure") + .filter(([environmentId], index) => + results[index] === undefined + ? previous.some((failed) => failed.environmentId === environmentId) + : results[index]?._tag === "Failure", + ) .map(([environmentId, presentation]) => ({ environmentId, label: presentation.entry.target.label, @@ -312,14 +324,42 @@ export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet { + 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(() => { + void refresh(true); + }); + useEffect(() => { + if (active && connectedLimitsEnvironments) autoRefreshLimits(); + }, [active, connectedLimitsEnvironments]); + const failedLabels = failedEnvironments .filter( ({ environmentId }) => selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh }; + return { now, refreshing, failedLabels, refresh: refreshManually }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 59910c295547..6f00300f1acc 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -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, @@ -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), diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index d6d300ce35b0..97ca070787a4 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -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", () => ({ @@ -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" })); @@ -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" }, @@ -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"); @@ -175,5 +182,64 @@ 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( + + + , + ); + }); + 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( + + + , + ); + }); + 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(); + }); expect(state.refreshProviders).not.toHaveBeenCalled(); + let finishRefresh!: () => void; + state.refreshProviders.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRefresh = () => resolve(undefined); + }), + ); + state.presentations = new Map([[id, presentation]]); + await act(() => renderer.update()); + expect(state.refreshProviders).toHaveBeenCalledTimes(1); + const nextId = EnvironmentId.make(`${id}-next`); + state.presentations = new Map([...state.presentations, [nextId, presentation]]); + await act(() => renderer.update()); + expect(state.refreshProviders).toHaveBeenCalledTimes(2); + expect(state.refreshProviders).toHaveBeenLastCalledWith({ environmentId: nextId, input: {} }); + await act(() => finishRefresh()); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2da7414d9337..95fee0e4b52a 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -11,7 +11,8 @@ import { CircleDashedIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, @@ -165,21 +166,31 @@ export function UsagePage() { setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); }; + const refreshLimits = async (automatic = false) => { + try { + await Promise.all( + Array.from(presentations, ([environmentId, presentation]) => { + if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; + if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { + return refreshUsageLimits( + environmentId, + () => refreshProviders({ environmentId, input: {} }), + automatic, + ); + } + }), + ); + } finally { + setLimitsNow(Date.now()); + } + }; const refreshWindow = () => { if (refreshingRef.current) return; if (showingLimits) { refreshingRef.current = true; setIsRefreshing(true); - void Promise.all( - Array.from(presentations, ([environmentId, presentation]) => { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - return refreshProviders({ environmentId, input: {} }); - } - }), - ).finally(() => { - setLimitsNow(Date.now()); + void refreshLimits().finally(() => { refreshingRef.current = false; setIsRefreshing(false); }); @@ -201,6 +212,23 @@ export function UsagePage() { setIsRefreshing(false); }); }; + const connectedLimitsEnvironments = [...presentations] + .filter( + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + presentation.serverConfig !== null && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), + ) + .map(([environmentId]) => environmentId) + .sort() + .join(","); + const autoRefreshLimits = useEffectEvent(() => { + void refreshLimits(true); + }); + useEffect(() => { + if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); + }, [showingLimits, connectedLimitsEnvironments]); + const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` diff --git a/docs/user/usage.md b/docs/user/usage.md index 9b0f449ee757..dbef802509b3 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -60,7 +60,9 @@ the bar show each account's quota, countdown, and credits. Tap a row to open its The same account signed in on more than one environment, or reported by a hub as well, counts once. Filter with the environment dropdown to see what a single machine has. -If a window looks stale, refresh Limits to re-check every provider and hub. +Opening Limits checks the selected connected environments automatically. Each client waits at +least five minutes between automatic checks of an environment, including after a failed check. +If a window still looks stale, refresh Limits to re-check every provider and hub. Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the current model's limits without leaving the conversation. The result opens above the composer and diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 29f029c9d863..3022458f4491 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -6,11 +6,11 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { refreshUsage } from "./usage.ts"; +import { refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -182,3 +182,39 @@ describe("manual usage refresh", () => { unmount(); }); }); + +describe("limits refresh cooldown", () => { + it("coalesces in-flight calls and gates automatic refreshes after success or failure", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(1_000); + try { + for (const fails of [false, true]) { + const id = EnvironmentId.make(`limits-${fails}`); + const pending = Promise.withResolvers(); + const refresh = vi.fn(() => pending.promise); + const first = refreshUsageLimits(id, refresh, true); + await refreshUsageLimits(id, refresh, true); + await refreshUsageLimits(id, refresh); + expect(refresh).toHaveBeenCalledTimes(1); + if (fails) { + pending.reject(new Error("unavailable")); + await expect(first).rejects.toThrow("unavailable"); + } else { + pending.resolve(); + await first; + } + const next = vi.fn(async () => undefined); + clock.mockReturnValue(300_999); + await refreshUsageLimits(id, next, true); + expect(next).not.toHaveBeenCalled(); + clock.mockReturnValue(301_000); + await refreshUsageLimits(id, next, true); + expect(next).toHaveBeenCalledTimes(1); + await refreshUsageLimits(id, next); + expect(next).toHaveBeenCalledTimes(2); + clock.mockReturnValue(1_000); + } + } finally { + clock.mockRestore(); + } + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 10a565a0c24f..17b2be57fa71 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -9,6 +9,25 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); +const limitsRefreshAfter = new Map(); + +export async function refreshUsageLimits( + environmentId: EnvironmentId, + refresh: () => Promise, + automatic = false, +): Promise { + const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; + // @effect-diagnostics-next-line globalDate:off + if (refreshAfter === Infinity || (automatic && Date.now() < refreshAfter)) return; + limitsRefreshAfter.set(environmentId, Infinity); + try { + return await refresh(); + } finally { + // @effect-diagnostics-next-line globalDate:off + limitsRefreshAfter.set(environmentId, Date.now() + 5 * 60_000); + } +} + /** Refresh pricing, then await each selected environment's rescan while it remains connected. */ export async function refreshUsage({ registry, From 5a58fca73b82658fbced1d9f44bdacd56c5d8ab9 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:40:48 +0200 Subject: [PATCH 2/3] fix(mobile): preserve errors across limits refreshes --- .../features/usage/UsageLimitsSection.test.ts | 72 +++++++++++++++++++ .../src/features/usage/UsageLimitsSection.tsx | 13 ++-- 2 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/features/usage/UsageLimitsSection.test.ts diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts new file mode 100644 index 000000000000..d844f0486730 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts @@ -0,0 +1,72 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { 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) => { + 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"; + +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"]); +}); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 56ed9c79c07b..d7d310431361 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -310,8 +310,9 @@ export function useRefreshLimits( ), ), ); - setFailedEnvironments((previous) => - connected + setFailedEnvironments((previous) => [ + ...previous.filter(({ environmentId }) => !connected.some(([id]) => id === environmentId)), + ...connected .filter(([environmentId], index) => results[index] === undefined ? previous.some((failed) => failed.environmentId === environmentId) @@ -321,7 +322,7 @@ export function useRefreshLimits( environmentId, label: presentation.entry.target.label, })), - ); + ]); } finally { setNow(Date.now()); } @@ -348,11 +349,9 @@ export function useRefreshLimits( .map(([environmentId]) => environmentId) .sort() .join(","); - const autoRefreshLimits = useEffectEvent(() => { - void refresh(true); - }); + const autoRefreshLimits = useEffectEvent(() => refresh(true)); useEffect(() => { - if (active && connectedLimitsEnvironments) autoRefreshLimits(); + if (active && connectedLimitsEnvironments) void autoRefreshLimits(); }, [active, connectedLimitsEnvironments]); const failedLabels = failedEnvironments From 23557bf3d65a1f4500a3c2c6394bb330229b62f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:35:51 -0700 Subject: [PATCH 3/3] fix(usage): preserve refresh results and await active checks Apply mobile failure results as each environment settles so slower batches cannot overwrite newer checks. Manual refreshes await the current provider request and retain their spinner until it finishes. Model: GPT-6. Harness: Codex. --- .../features/usage/UsageLimitsSection.test.ts | 47 ++++++++++++++++++- .../src/features/usage/UsageLimitsSection.tsx | 30 +++++------- .../usage/UsagePage.refresh.test.tsx | 27 +++++++++++ .../client-runtime/src/state/usage.test.ts | 19 +++++--- packages/client-runtime/src/state/usage.ts | 24 ++++++---- 5 files changed, 114 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts index d844f0486730..f8d769355f77 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts +++ b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts @@ -1,5 +1,5 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { expect, it, vi } from "vite-plus/test"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ values: [] as unknown[], @@ -41,6 +41,14 @@ vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: nu 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"); @@ -70,3 +78,40 @@ it("keeps a newer environment failure when an older refresh finishes", async () 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 | 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"]); +}); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index d7d310431361..5383602869bb 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -301,28 +301,22 @@ export function useRefreshLimits( (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); try { - const results = await Promise.all( - connected.map(([environmentId]) => - refreshUsageLimits( + await Promise.all( + connected.map(async ([environmentId, presentation]) => { + const result = await refreshUsageLimits( environmentId, () => 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 }] + : []), + ]); + }), ); - setFailedEnvironments((previous) => [ - ...previous.filter(({ environmentId }) => !connected.some(([id]) => id === environmentId)), - ...connected - .filter(([environmentId], index) => - results[index] === undefined - ? previous.some((failed) => failed.environmentId === environmentId) - : results[index]?._tag === "Failure", - ) - .map(([environmentId, presentation]) => ({ - environmentId, - label: presentation.entry.target.label, - })), - ]); } finally { setNow(Date.now()); } diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index 97ca070787a4..f8958ab33d32 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -243,3 +243,30 @@ it("waits for connection and refreshes new environments during a slow refresh", 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((resolve) => { + finishRefresh = () => resolve(undefined); + }); + state.refreshProviders.mockImplementationOnce(() => pending); + await act(() => { + renderer = create(); + }); + 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); +}); diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 3022458f4491..98eb46e13ca8 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -184,24 +184,31 @@ describe("manual usage refresh", () => { }); describe("limits refresh cooldown", () => { - it("coalesces in-flight calls and gates automatic refreshes after success or failure", async () => { + it("joins manual calls and gates automatic refreshes after success or failure", async () => { const clock = vi.spyOn(Date, "now").mockReturnValue(1_000); try { for (const fails of [false, true]) { const id = EnvironmentId.make(`limits-${fails}`); - const pending = Promise.withResolvers(); + const pending = Promise.withResolvers(); const refresh = vi.fn(() => pending.promise); const first = refreshUsageLimits(id, refresh, true); await refreshUsageLimits(id, refresh, true); - await refreshUsageLimits(id, refresh); + const manual = refreshUsageLimits(id, refresh); + const settled = vi.fn(); + void manual.then(settled, settled); + expect(settled).not.toHaveBeenCalled(); expect(refresh).toHaveBeenCalledTimes(1); if (fails) { + const firstFailure = expect(first).rejects.toThrow("unavailable"); + const manualFailure = expect(manual).rejects.toThrow("unavailable"); pending.reject(new Error("unavailable")); - await expect(first).rejects.toThrow("unavailable"); + await Promise.all([firstFailure, manualFailure]); } else { - pending.resolve(); - await first; + pending.resolve("quota"); + expect(await first).toBe("quota"); + expect(await manual).toBe("quota"); } + expect(settled).toHaveBeenCalledTimes(1); const next = vi.fn(async () => undefined); clock.mockReturnValue(300_999); await refreshUsageLimits(id, next, true); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 17b2be57fa71..8a2b1a44a951 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -10,22 +10,30 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); const limitsRefreshAfter = new Map(); +const limitsRefreshes = new Map>(); export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, ): Promise { + const pending = limitsRefreshes.get(environmentId); + if (pending !== undefined) { + // Manual refresh waits for the current check; automatic refresh does not repeat it. + return automatic ? undefined : ((await pending) as A); + } const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; // @effect-diagnostics-next-line globalDate:off - if (refreshAfter === Infinity || (automatic && Date.now() < refreshAfter)) return; - limitsRefreshAfter.set(environmentId, Infinity); - try { - return await refresh(); - } finally { - // @effect-diagnostics-next-line globalDate:off - limitsRefreshAfter.set(environmentId, Date.now() + 5 * 60_000); - } + if (automatic && Date.now() < refreshAfter) return; + const current = Promise.resolve() + .then(refresh) + .finally(() => { + limitsRefreshes.delete(environmentId); + // @effect-diagnostics-next-line globalDate:off + limitsRefreshAfter.set(environmentId, Date.now() + 5 * 60_000); + }); + limitsRefreshes.set(environmentId, current); + return await current; } /** Refresh pricing, then await each selected environment's rescan while it remains connected. */