FN-5886: show empty usage state after empty fetch

Handle empty usage responses without leaving the panel in its loading skeleton.

- add a hasFetched flag to useUsageData so the UI can distinguish initial loading from an empty completed response
- update UsageIndicator to render the skeleton only before the first fetch completes and show the empty state afterward
- refresh dashboard hook and component tests to cover empty fetches, refresh behavior, and the new loading semantics

Files changed:
 .../dashboard/app/components/UsageIndicator.tsx    |  19 +-
 .../components/__tests__/UsageIndicator.test.tsx   | 395 +++++++++++----------
 .../app/hooks/__tests__/useUsageData.test.ts       |  42 ++-
 packages/dashboard/app/hooks/useUsageData.ts       |   5 +
 4 files changed, 245 insertions(+), 216 deletions(-)

Fusion-Task-Id: FN-5886

Fusion-Task-Lineage: 8fa901a9-3167-4e33-a082-e5de95f692da
This commit is contained in:
gsxdsm
2026-06-02 09:24:42 -07:00
parent fa68edf9fa
commit cf3b9a575e
4 changed files with 245 additions and 216 deletions

View File

@@ -558,7 +558,7 @@ function UsageSkeleton() {
* reset timers, and pace indicators.
*/
export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: UsageIndicatorProps) {
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
const { providers, loading, error, lastUpdated, hasFetched, refresh } = useUsageData({
autoRefresh: isOpen, // Only poll when modal is open
});
@@ -581,7 +581,6 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
const contentRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(isOpen);
const hasCompletedInitialFetchRef = useRef(false);
const [savedSize, setSavedSize] = useState<ModalSize | null>(() => getSavedModalSize(projectId));
useEffect(() => {
@@ -616,20 +615,6 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
};
}, [isOpen, isDesktopViewport, projectId]);
// Reset initial fetch flag when modal closes to show skeleton on next open
useEffect(() => {
if (!isOpen) {
hasCompletedInitialFetchRef.current = false;
}
}, [isOpen]);
// Track when initial fetch completes (providers are populated)
useEffect(() => {
if (providers.length > 0) {
hasCompletedInitialFetchRef.current = true;
}
}, [providers.length]);
useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -923,7 +908,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
</div>
<div className="usage-content" ref={contentRef}>
{(loading || (!hasCompletedInitialFetchRef.current && !error)) && providers.length === 0 ? (
{(!hasFetched && !error) && providers.length === 0 ? (
<UsageSkeleton />
) : error && providers.length === 0 ? (
<div className="usage-error">

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useUsageData } from "../useUsageData";
import * as api from "../../api";
@@ -7,7 +7,7 @@ describe("useUsageData", () => {
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
beforeEach(() => {
mockFetchUsageData.mockClear();
mockFetchUsageData.mockReset();
});
it("fetches data on initial mount", async () => {
@@ -25,16 +25,16 @@ describe("useUsageData", () => {
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
// Should be loading initially
expect(result.current.loading).toBe(true);
expect(result.current.providers).toEqual([]);
expect(result.current.hasFetched).toBe(false);
// Wait for data to load
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.providers).toEqual(mockData.providers);
expect(result.current.error).toBeNull();
expect(result.current.lastUpdated).toBeInstanceOf(Date);
expect(result.current.hasFetched).toBe(true);
});
it("handles fetch errors", async () => {
@@ -42,10 +42,13 @@ describe("useUsageData", () => {
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
expect(result.current.hasFetched).toBe(false);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("Network error");
expect(result.current.providers).toEqual([]);
expect(result.current.hasFetched).toBe(true);
});
it("manual refresh fetches new data", async () => {
@@ -64,11 +67,14 @@ describe("useUsageData", () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.providers).toEqual(mockData1.providers);
expect(result.current.hasFetched).toBe(true);
// Manual refresh
await result.current.refresh();
await act(async () => {
await result.current.refresh();
});
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
expect(result.current.hasFetched).toBe(true);
});
it("clears error on successful manual refresh after error", async () => {
@@ -82,12 +88,29 @@ describe("useUsageData", () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("Network error");
expect(result.current.hasFetched).toBe(true);
// Manual refresh
await result.current.refresh();
await act(async () => {
await result.current.refresh();
});
await waitFor(() => expect(result.current.error).toBeNull());
expect(result.current.providers).toHaveLength(1);
expect(result.current.hasFetched).toBe(true);
});
it("sets hasFetched to true after a successful empty fetch", async () => {
mockFetchUsageData.mockResolvedValue({ providers: [] });
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
expect(result.current.hasFetched).toBe(false);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.providers).toEqual([]);
expect(result.current.error).toBeNull();
expect(result.current.hasFetched).toBe(true);
});
it("exports the correct interface", () => {
@@ -95,7 +118,7 @@ describe("useUsageData", () => {
});
it("returns expected default values before first fetch", () => {
mockFetchUsageData.mockImplementation(() => new Promise(() => {})); // Never resolves
mockFetchUsageData.mockImplementation(() => new Promise(() => {}));
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
@@ -103,6 +126,7 @@ describe("useUsageData", () => {
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull();
expect(result.current.lastUpdated).toBeNull();
expect(result.current.hasFetched).toBe(false);
expect(typeof result.current.refresh).toBe("function");
});
});

View File

@@ -7,6 +7,7 @@ interface UsageDataState {
loading: boolean;
error: string | null;
lastUpdated: Date | null;
hasFetched: boolean;
}
interface UseUsageDataOptions {
@@ -34,6 +35,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
loading: true,
error: null,
lastUpdated: null,
hasFetched: false,
});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -57,6 +59,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
loading: false,
error: null,
lastUpdated: new Date(),
hasFetched: true,
});
} catch (err) {
// Don't update state if the request was aborted
@@ -66,6 +69,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
...prev,
loading: false,
error: getErrorMessage(err) || "Failed to fetch usage data",
hasFetched: true,
}));
}
}, []);
@@ -112,6 +116,7 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
hasFetched: state.hasFetched,
refresh,
};
}