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. * reset timers, and pace indicators.
*/ */
export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: UsageIndicatorProps) { 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 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 contentRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(isOpen); const wasOpenRef = useRef(isOpen);
const hasCompletedInitialFetchRef = useRef(false);
const [savedSize, setSavedSize] = useState<ModalSize | null>(() => getSavedModalSize(projectId)); const [savedSize, setSavedSize] = useState<ModalSize | null>(() => getSavedModalSize(projectId));
useEffect(() => { useEffect(() => {
@@ -616,20 +615,6 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
}; };
}, [isOpen, isDesktopViewport, projectId]); }, [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(() => { useEffect(() => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return;
@@ -923,7 +908,7 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
</div> </div>
<div className="usage-content" ref={contentRef}> <div className="usage-content" ref={contentRef}>
{(loading || (!hasCompletedInitialFetchRef.current && !error)) && providers.length === 0 ? ( {(!hasFetched && !error) && providers.length === 0 ? (
<UsageSkeleton /> <UsageSkeleton />
) : error && providers.length === 0 ? ( ) : error && providers.length === 0 ? (
<div className="usage-error"> <div className="usage-error">

View File

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

View File

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