diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index cc8ccfa..6536677 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -65,19 +65,20 @@ export function DashboardPage() { const queryClient = useQueryClient() const { confirm, ConfirmComponent } = useConfirm() const [deletingStackId, setDeletingStackId] = useState(null) - const [sortBy, setSortBy] = useState('name') - const [statusFilter, setStatusFilter] = useState('all') + // Read the persisted sort/filter in the initialiser rather than in a mount + // effect. An effect would render once with the default and again with the + // stored value, which is both a visible flash of the wrong sort order and + // what react-hooks/set-state-in-effect flags. + const [sortBy, setSortBy] = useState( + () => (localStorage.getItem('dashboard-sort') as SortOption) || 'name', + ) + const [statusFilter, setStatusFilter] = useState( + () => (localStorage.getItem('dashboard-filter') as StatusFilter) || 'all', + ) const [isRefreshing, setIsRefreshing] = useState(false) const { containers: metricsContainers, aggregates, latestMetrics, isConnected: metricsConnected, ws: metricsWs } = useDashboardMetrics() - useEffect(() => { - const savedSort = localStorage.getItem('dashboard-sort') as SortOption - const savedFilter = localStorage.getItem('dashboard-filter') as StatusFilter - if (savedSort) setSortBy(savedSort) - if (savedFilter) setStatusFilter(savedFilter) - }, []) - useEffect(() => { localStorage.setItem('dashboard-sort', sortBy) localStorage.setItem('dashboard-filter', statusFilter) diff --git a/frontend/src/pages/__tests__/DashboardPage.test.tsx b/frontend/src/pages/__tests__/DashboardPage.test.tsx index 2a6409b..73520ea 100644 --- a/frontend/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend/src/pages/__tests__/DashboardPage.test.tsx @@ -70,8 +70,12 @@ vi.mock('@/components/dashboard/DashboardHeader', () => ({ vi.mock('@/components/dashboard/DashboardMetricsTab', () => ({ DashboardMetricsTab: () =>
, })) +// Surfaces the sort/filter the page hands down, so the localStorage-restore +// contract is observable without exercising StacksTab's internals. vi.mock('@/components/dashboard/StacksTab', () => ({ - StacksTab: () =>
, + StacksTab: ({ sortBy, statusFilter }: { sortBy: string; statusFilter: string }) => ( +
+ ), })) vi.mock('@/components/dashboard/ContainersOverviewTab', () => ({ ContainersOverviewTab: () =>
, @@ -144,6 +148,9 @@ function renderPage(route = '/') { describe('DashboardPage', () => { beforeEach(() => { vi.clearAllMocks() + // The page persists sort/filter on every change, so state leaks between + // tests unless this is cleared. + localStorage.clear() listDirectories.mockResolvedValue([]) listStacks.mockResolvedValue([]) dashboardStats.mockResolvedValue({ runningContainers: 0 }) @@ -243,6 +250,39 @@ describe('DashboardPage', () => { expect(screen.getByText('Create Your First Stack')).toBeInTheDocument() }) + // Pins the localStorage-restore contract itself, which had no coverage + // before agent-os-14b moved the read out of a mount effect and into the + // useState initialiser. These assert the restored values are what the page + // hands to StacksTab; they deliberately do NOT claim to distinguish the two + // implementations. They cannot: StacksTab is gated behind the loading state, + // so it first renders after effects have already flushed, and both versions + // look identical from the DOM. Verified by reverting the fix — these still + // passed. The guard against a relapse to setState-in-effect is the + // react-hooks/set-state-in-effect lint rule, not this file. + it('restores the persisted sort and filter', async () => { + localStorage.setItem('dashboard-sort', 'status') + localStorage.setItem('dashboard-filter', 'running') + listStacks.mockResolvedValue([makeStack({ id: 's1' })]) + listDirectories.mockResolvedValue([makeDir({ path: '/stacks/s1' })]) + + renderPage('/?tab=stacks') + + const tab = await screen.findByTestId('tab-stacks') + expect(tab).toHaveAttribute('data-sort-by', 'status') + expect(tab).toHaveAttribute('data-status-filter', 'running') + }) + + it('falls back to the defaults when nothing is persisted', async () => { + listStacks.mockResolvedValue([makeStack({ id: 's1' })]) + listDirectories.mockResolvedValue([makeDir({ path: '/stacks/s1' })]) + + renderPage('/?tab=stacks') + + const tab = await screen.findByTestId('tab-stacks') + expect(tab).toHaveAttribute('data-sort-by', 'name') + expect(tab).toHaveAttribute('data-status-filter', 'all') + }) + it('hides the Quick Start empty state once stacks exist', async () => { listStacks.mockResolvedValue([makeStack({ id: 's1' })]) listDirectories.mockResolvedValue([makeDir({ path: '/stacks/s1' })])