From 72da61fcd4663622a2be7cc634559389c3c8bad0 Mon Sep 17 00:00:00 2001 From: omattsson Date: Sat, 14 Mar 2026 21:55:12 +0100 Subject: [PATCH 1/2] feat(frontend): auto-refresh items list on WebSocket events (#7) Refactored fetchItems into a useCallback with a silent param so that background refreshes triggered by WebSocket events do not surface error alerts on failure. Wired item.created, item.updated, and item.deleted event handlers to call fetchItems({ silent: true }) so the list stays in sync across tabs without a hard reload. Bug fixes: - Error state was not cleared (setError(null)) on a successful fetch - Silent background refreshes were incorrectly showing the error alert on transient fetch failures Tests extended to 63 unit tests covering re-fetch assertions, silent- fail behaviour, UI updates from events, rapid-event deduplication, and error recovery. Refs #7 --- .../src/pages/Items/__tests__/Items.test.tsx | 302 ++++++++++++++++++ frontend/src/pages/Items/index.tsx | 33 +- 2 files changed, 323 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/Items/__tests__/Items.test.tsx b/frontend/src/pages/Items/__tests__/Items.test.tsx index ef27217..1ff3380 100644 --- a/frontend/src/pages/Items/__tests__/Items.test.tsx +++ b/frontend/src/pages/Items/__tests__/Items.test.tsx @@ -187,6 +187,75 @@ describe('Items Page', () => { expect(screen.getByText('—')).toBeInTheDocument(); }); + it('re-fetches items when item.created event arrives', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(1); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'New', price: 1.0, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + }); + + it('re-fetches items when item.updated event arrives', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(1); + + act(() => { + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget Pro', price: 29.99, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + }); + + it('re-fetches items when item.deleted event arrives', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + expect(itemService.list).toHaveBeenCalledTimes(1); + + act(() => { + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + }); + it('unsubscribes on unmount', async () => { const { mockUnsubscribe } = setupSubscribeMock(); (itemService.list as ReturnType).mockResolvedValue([]); @@ -201,4 +270,237 @@ describe('Items Page', () => { expect(mockUnsubscribe).toHaveBeenCalledTimes(3); }); + + // --------------------------------------------------------------------------- + // Silent-fail: background refresh errors must NOT surface an error alert + // --------------------------------------------------------------------------- + + it('keeps existing items and shows no error when background refresh fails on item.created', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockRejectedValueOnce(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'NewWidget', price: 5.0, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + + it('keeps existing items and shows no error when background refresh fails on item.updated', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockRejectedValueOnce(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget Pro', price: 29.99, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + + it('keeps existing items and shows no error when background refresh fails on item.deleted', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockRejectedValueOnce(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(2); + }); + + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // UI update: DOM must reflect the fresh data returned by the background fetch + // --------------------------------------------------------------------------- + + it('renders new item in table after item.created triggers re-fetch', async () => { + const { handlers } = setupSubscribeMock(); + const updatedItems = [ + ...mockItems, + { id: 3, name: 'NewWidget', price: 5.0, created_at: '2026-01-03T00:00:00Z', updated_at: '2026-01-03T00:00:00Z' }, + ]; + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockResolvedValueOnce(updatedItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + expect(screen.queryByText('NewWidget')).not.toBeInTheDocument(); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'NewWidget', price: 5.0, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(screen.getByText('NewWidget')).toBeInTheDocument(); + }); + expect(screen.getByText('$5.00')).toBeInTheDocument(); + }); + + it('renders updated item data in table after item.updated triggers re-fetch', async () => { + const { handlers } = setupSubscribeMock(); + const updatedItems = [ + { ...mockItems[0], name: 'Widget Pro', price: 29.99 }, + mockItems[1], + ]; + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockResolvedValueOnce(updatedItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget Pro', price: 29.99, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(screen.getByText('Widget Pro')).toBeInTheDocument(); + }); + expect(screen.queryByText('Widget')).not.toBeInTheDocument(); + expect(screen.getByText('$29.99')).toBeInTheDocument(); + }); + + it('removes deleted item from table after item.deleted triggers re-fetch', async () => { + const { handlers } = setupSubscribeMock(); + const updatedItems = [mockItems[1]]; // Widget (id:1) removed by server + (itemService.list as ReturnType) + .mockResolvedValueOnce(mockItems) + .mockResolvedValueOnce(updatedItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); + }); + + await waitFor(() => { + expect(screen.queryByText('Widget')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // Rapid successive events: no race condition / state corruption + // --------------------------------------------------------------------------- + + it('clears error state and shows items when background refresh succeeds after initial load failure', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to load items')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 1, name: 'Widget', price: 9.99, created_at: '', updated_at: '' }, + }); + }); + + await waitFor(() => { + expect(screen.queryByText('Failed to load items')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); + + it('handles rapid successive WebSocket events without state corruption', async () => { + const { handlers } = setupSubscribeMock(); + (itemService.list as ReturnType).mockResolvedValue(mockItems); + + render(); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + act(() => { + handlers['item.created']({ + type: 'item.created', + payload: { id: 3, name: 'ItemA', price: 1.0, created_at: '', updated_at: '' }, + }); + handlers['item.updated']({ + type: 'item.updated', + payload: { id: 1, name: 'Widget', price: 9.99, created_at: '', updated_at: '' }, + }); + handlers['item.deleted']({ type: 'item.deleted', payload: { id: 2 } }); + }); + + await waitFor(() => { + expect(itemService.list).toHaveBeenCalledTimes(4); // 1 initial + 3 events + }); + + // Component must not crash; latest mocked data (mockItems) should be shown + expect(screen.getByText('Widget')).toBeInTheDocument(); + expect(screen.getByText('Gadget')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/Items/index.tsx b/frontend/src/pages/Items/index.tsx index 88329a1..f231f60 100644 --- a/frontend/src/pages/Items/index.tsx +++ b/frontend/src/pages/Items/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Typography, Box, @@ -33,33 +33,42 @@ const Items = () => { const [toast, setToast] = useState({ open: false, message: '', severity: 'success' }); const { subscribe } = useWebSocketContext(); - useEffect(() => { - const fetchItems = async () => { - try { - const data = await itemService.list(); - setItems(data); - } catch { + const fetchItems = useCallback(async (silent = false) => { + try { + const data = await itemService.list(); + setItems(data); + setError(null); + } catch { + if (!silent) { setError('Failed to load items'); - } finally { - setLoading(false); } - }; - fetchItems(); + } }, []); + useEffect(() => { + const load = async () => { + await fetchItems(); + setLoading(false); + }; + void load(); + }, [fetchItems]); + useEffect(() => { const unsubCreated = subscribe('item.created', (msg: WebSocketMessage) => { const item = msg.payload as Item; setToast({ open: true, message: `Item '${item.name}' created`, severity: 'success' }); + void fetchItems(true); }); const unsubUpdated = subscribe('item.updated', (msg: WebSocketMessage) => { const item = msg.payload as Item; setToast({ open: true, message: `Item '${item.name}' updated`, severity: 'info' }); + void fetchItems(true); }); const unsubDeleted = subscribe('item.deleted', () => { setToast({ open: true, message: 'Item deleted', severity: 'warning' }); + void fetchItems(true); }); return () => { @@ -67,7 +76,7 @@ const Items = () => { unsubUpdated(); unsubDeleted(); }; - }, [subscribe]); + }, [subscribe, fetchItems]); const handleToastClose = () => { setToast((prev) => ({ ...prev, open: false })); From e6757b0799133a3d613c34924e7fe44ccf48c21f Mon Sep 17 00:00:00 2001 From: omattsson Date: Sat, 14 Mar 2026 22:08:15 +0100 Subject: [PATCH 2/2] fix: debounce and guard concurrent fetchItems calls on WebSocket events --- .../src/pages/Items/__tests__/Items.test.tsx | 34 ++++++++++++++----- frontend/src/pages/Items/index.tsx | 24 +++++++++---- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/Items/__tests__/Items.test.tsx b/frontend/src/pages/Items/__tests__/Items.test.tsx index 1ff3380..3a433d4 100644 --- a/frontend/src/pages/Items/__tests__/Items.test.tsx +++ b/frontend/src/pages/Items/__tests__/Items.test.tsx @@ -52,6 +52,7 @@ describe('Items Page', () => { afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); + vi.useRealTimers(); }); it('shows a loading spinner initially', () => { @@ -199,6 +200,8 @@ describe('Items Page', () => { expect(itemService.list).toHaveBeenCalledTimes(1); + vi.useFakeTimers(); + act(() => { handlers['item.created']({ type: 'item.created', @@ -206,9 +209,11 @@ describe('Items Page', () => { }); }); - await waitFor(() => { - expect(itemService.list).toHaveBeenCalledTimes(2); + await act(async () => { + vi.runAllTimers(); }); + + expect(itemService.list).toHaveBeenCalledTimes(2); }); it('re-fetches items when item.updated event arrives', async () => { @@ -223,6 +228,8 @@ describe('Items Page', () => { expect(itemService.list).toHaveBeenCalledTimes(1); + vi.useFakeTimers(); + act(() => { handlers['item.updated']({ type: 'item.updated', @@ -230,9 +237,11 @@ describe('Items Page', () => { }); }); - await waitFor(() => { - expect(itemService.list).toHaveBeenCalledTimes(2); + await act(async () => { + vi.runAllTimers(); }); + + expect(itemService.list).toHaveBeenCalledTimes(2); }); it('re-fetches items when item.deleted event arrives', async () => { @@ -247,13 +256,17 @@ describe('Items Page', () => { expect(itemService.list).toHaveBeenCalledTimes(1); + vi.useFakeTimers(); + act(() => { handlers['item.deleted']({ type: 'item.deleted', payload: { id: 1 } }); }); - await waitFor(() => { - expect(itemService.list).toHaveBeenCalledTimes(2); + await act(async () => { + vi.runAllTimers(); }); + + expect(itemService.list).toHaveBeenCalledTimes(2); }); it('unsubscribes on unmount', async () => { @@ -483,6 +496,8 @@ describe('Items Page', () => { expect(screen.getByText('Widget')).toBeInTheDocument(); }); + vi.useFakeTimers(); + act(() => { handlers['item.created']({ type: 'item.created', @@ -495,10 +510,13 @@ describe('Items Page', () => { handlers['item.deleted']({ type: 'item.deleted', payload: { id: 2 } }); }); - await waitFor(() => { - expect(itemService.list).toHaveBeenCalledTimes(4); // 1 initial + 3 events + // All 3 events are coalesced into a single debounced fetch + await act(async () => { + vi.runAllTimers(); }); + expect(itemService.list).toHaveBeenCalledTimes(2); // 1 initial + 1 debounced + // Component must not crash; latest mocked data (mockItems) should be shown expect(screen.getByText('Widget')).toBeInTheDocument(); expect(screen.getByText('Gadget')).toBeInTheDocument(); diff --git a/frontend/src/pages/Items/index.tsx b/frontend/src/pages/Items/index.tsx index f231f60..3caf86a 100644 --- a/frontend/src/pages/Items/index.tsx +++ b/frontend/src/pages/Items/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Typography, Box, @@ -32,14 +32,20 @@ const Items = () => { const [error, setError] = useState(null); const [toast, setToast] = useState({ open: false, message: '', severity: 'success' }); const { subscribe } = useWebSocketContext(); + const fetchCounterRef = useRef(0); + const debounceRef = useRef | null>(null); const fetchItems = useCallback(async (silent = false) => { + fetchCounterRef.current += 1; + const myCounter = fetchCounterRef.current; try { const data = await itemService.list(); - setItems(data); - setError(null); + if (myCounter === fetchCounterRef.current) { + setItems(data); + setError(null); + } } catch { - if (!silent) { + if (!silent && myCounter === fetchCounterRef.current) { setError('Failed to load items'); } } @@ -57,24 +63,28 @@ const Items = () => { const unsubCreated = subscribe('item.created', (msg: WebSocketMessage) => { const item = msg.payload as Item; setToast({ open: true, message: `Item '${item.name}' created`, severity: 'success' }); - void fetchItems(true); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { void fetchItems(true); }, 300); }); const unsubUpdated = subscribe('item.updated', (msg: WebSocketMessage) => { const item = msg.payload as Item; setToast({ open: true, message: `Item '${item.name}' updated`, severity: 'info' }); - void fetchItems(true); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { void fetchItems(true); }, 300); }); const unsubDeleted = subscribe('item.deleted', () => { setToast({ open: true, message: 'Item deleted', severity: 'warning' }); - void fetchItems(true); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { void fetchItems(true); }, 300); }); return () => { unsubCreated(); unsubUpdated(); unsubDeleted(); + if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [subscribe, fetchItems]);