Skip to content

Commit cd5fc34

Browse files
fix(overview): show loading skeleton on period change (#2554)
The overview dashboard kept stale data visible while a new range loaded, because the resource retains its previous value during a refetch. Track the range the visible data belongs to and show the skeleton only while a newer range is loading — background SSE ping refetches still update in place without flashing the skeleton. Applied to both the per-agent Overview and the workspace GlobalOverview.
1 parent 2c81871 commit cd5fc34

4 files changed

Lines changed: 75 additions & 5 deletions

File tree

packages/frontend/src/pages/GlobalOverview.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ const GlobalOverview: Component = () => {
251251
(p) => getOverview(p.range) as Promise<OverviewResponse>,
252252
);
253253

254+
// Show the skeleton on a range change, but not on the frequent background SSE
255+
// `_ping` refetches (those update in place). Track the range the visible
256+
// overview belongs to; while a newer range is loading, treat it as changing.
257+
const [loadedRange, setLoadedRange] = createSignal(effectiveChartRange());
258+
createEffect(() => {
259+
if (!overview.loading && overview() !== undefined) setLoadedRange(effectiveChartRange());
260+
});
261+
const rangeChanging = () => overview.loading && loadedRange() !== effectiveChartRange();
262+
254263
const [agents] = createResource(
255264
() => ({ _agentPing: agentPing(), _messagePing: messagePing() }),
256265
async () => {
@@ -661,6 +670,7 @@ const GlobalOverview: Component = () => {
661670
when={
662671
!hasNoAgents() &&
663672
!hasNoProviders() &&
673+
!rangeChanging() &&
664674
overview() !== undefined &&
665675
agents() !== undefined &&
666676
providers() !== undefined

packages/frontend/src/pages/Overview.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ const Overview: Component = () => {
177177
(p) => getOverview(p.range, p.agentName) as Promise<OverviewData>,
178178
);
179179

180+
// The resource re-fetches on range, agent, and every SSE `_ping`. We only want
181+
// the loading skeleton on a range change — not on the frequent background ping
182+
// refetches (which should update in place). Track the range the visible data
183+
// belongs to; while a newer range is loading, treat it as a range change.
184+
const [loadedRange, setLoadedRange] = createSignal(effectiveRange());
185+
createEffect(() => {
186+
if (!data.loading && data() !== undefined) setLoadedRange(effectiveRange());
187+
});
188+
const rangeChanging = () => data.loading && loadedRange() !== effectiveRange();
189+
180190
const showDashboard = () => {
181191
const d = data();
182192
return !!d && (d.has_data !== false || d.has_providers === true);
@@ -402,7 +412,7 @@ const Overview: Component = () => {
402412
</div>
403413

404414
<Show
405-
when={!billing.loading && (data() !== undefined || !data.loading)}
415+
when={!billing.loading && (data() !== undefined || !data.loading) && !rangeChanging()}
406416
fallback={<OverviewSkeleton />}
407417
>
408418
<Show when={!data.error} fallback={<ErrorState error={data.error} onRetry={refetch} />}>

packages/frontend/tests/pages/GlobalOverviewFilter.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,29 @@ describe('GlobalOverview filter onUnselectAll', () => {
363363
expect(apiMocks.getOverviewProviderUsage).toHaveBeenCalledTimes(2);
364364
});
365365

366+
it('shows the skeleton on a range change but not on a background ping refetch', async () => {
367+
const { container, queryByTestId } = render(() => <GlobalOverview />);
368+
369+
// Wait for the initial load to paint the dashboard (skeleton gone).
370+
await waitFor(() => expect(container.querySelector('.chart-card')).not.toBeNull());
371+
expect(queryByTestId('global-overview-skeleton')).toBeNull();
372+
373+
// A background SSE ping refetch keeps the dashboard in place (no skeleton).
374+
apiMocks.getOverview.mockReturnValue(new Promise(() => {}));
375+
sseMocks.bumpMessage?.();
376+
await Promise.resolve();
377+
expect(queryByTestId('global-overview-skeleton')).toBeNull();
378+
expect(container.querySelector('.chart-card')).not.toBeNull();
379+
380+
// A range change swaps in the skeleton while the new range loads.
381+
const rangeSelect = [...container.querySelectorAll('select')].find((s) =>
382+
[...s.options].some((o) => o.value === '365d'),
383+
) as HTMLSelectElement;
384+
rangeSelect.value = '24h';
385+
fireEvent.change(rangeSelect);
386+
await waitFor(() => expect(queryByTestId('global-overview-skeleton')).not.toBeNull());
387+
});
388+
366389
it('links the harness total-requests count to the agent-scoped Requests log', async () => {
367390
const { container } = render(() => <GlobalOverview />);
368391
await waitFor(() => {

packages/frontend/tests/pages/Overview.test.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { render, screen, fireEvent } from '@solidjs/testing-library';
3+
import { createSignal } from 'solid-js';
4+
5+
// Controllable SSE ping: lets a test bump `messagePing()` to drive a background
6+
// refetch and assert it does NOT flash the skeleton (unlike a range change).
7+
const pingBox = vi.hoisted(() => ({ read: (): number => 0, set: (_: number) => {} }));
38

49
let mockAgentName = 'test-agent';
510
let mockLocationState: any = null;
@@ -33,7 +38,7 @@ vi.mock('../../src/services/api.js', () => ({
3338

3439
vi.mock('../../src/services/sse.js', () => ({
3540
pingCount: () => 0,
36-
messagePing: () => 0,
41+
messagePing: () => pingBox.read(),
3742
agentPing: () => 0,
3843
routingPing: () => 0,
3944
}));
@@ -261,6 +266,9 @@ describe('Overview', () => {
261266
localStorage.clear();
262267
localStorage.setItem('manifest_global_group', 'provider');
263268
sessionStorage.clear();
269+
const [ping, setPing] = createSignal(0);
270+
pingBox.read = ping;
271+
pingBox.set = setPing;
264272
mockIsRecentlyCreated.mockReturnValue(false);
265273
mockIsSetupPending.mockReturnValue(false);
266274
mockAgentName = 'test-agent';
@@ -306,19 +314,38 @@ describe('Overview', () => {
306314
expect(skeletons.length).toBeGreaterThan(0);
307315
});
308316

309-
it('keeps showing stale data during refetch instead of skeletons', async () => {
317+
it('shows the loading skeleton when the range changes', async () => {
310318
mockGetOverview.mockResolvedValue(overviewData);
311319
const { container } = render(() => <Overview />);
312320
await vi.waitFor(() => {
313321
expect(container.textContent).toContain('$3.50');
314322
});
315323

316-
// Trigger a refetch that never resolves
324+
// Change the range; the new fetch never resolves.
317325
mockGetOverview.mockReturnValue(new Promise(() => {}));
318326
const select = container.querySelector('[data-testid="select"]') as HTMLSelectElement;
319327
await fireEvent.change(select, { target: { value: '24h' } });
320328

321-
// Should still show old data, not skeletons
329+
// Stale data is replaced by the skeleton while the new range loads.
330+
await vi.waitFor(() => {
331+
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
332+
});
333+
expect(container.textContent).not.toContain('$3.50');
334+
});
335+
336+
it('keeps showing data during a background ping refetch instead of skeletons', async () => {
337+
mockGetOverview.mockResolvedValue(overviewData);
338+
const { container } = render(() => <Overview />);
339+
await vi.waitFor(() => {
340+
expect(container.textContent).toContain('$3.50');
341+
});
342+
343+
// A background SSE ping refetch (same range) never resolves.
344+
mockGetOverview.mockReturnValue(new Promise(() => {}));
345+
pingBox.set(1);
346+
347+
// Old data stays put — no skeleton flash on in-place refreshes.
348+
await Promise.resolve();
322349
expect(container.textContent).toContain('$3.50');
323350
expect(container.querySelectorAll('.skeleton').length).toBe(0);
324351
});

0 commit comments

Comments
 (0)