From 4cd15bb8a04bf8df3fb292796a8f32d7533cacdc Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Sun, 15 Feb 2026 15:57:54 -0800 Subject: Optimize frontend with memoized FeedItem and efficient IntersectionObserver --- frontend/src/components/FeedItem.test.tsx | 48 +++++------- frontend/src/components/FeedItem.tsx | 75 ++++++------------ frontend/src/components/FeedItems.test.tsx | 58 +++++--------- frontend/src/components/FeedItems.tsx | 122 ++++++++++++++++++----------- 4 files changed, 137 insertions(+), 166 deletions(-) (limited to 'frontend/src') diff --git a/frontend/src/components/FeedItem.test.tsx b/frontend/src/components/FeedItem.test.tsx index 1c51dc3..ab2ca45 100644 --- a/frontend/src/components/FeedItem.test.tsx +++ b/frontend/src/components/FeedItem.test.tsx @@ -27,66 +27,54 @@ describe('FeedItem Component', () => { render(); expect(screen.getByText('Test Item')).toBeInTheDocument(); expect(screen.getByText(/Test Feed/)).toBeInTheDocument(); - // Check for relative time or date formatting? For now just check it renders }); - it('toggles star status', async () => { - vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response); - - render(); + it('calls onToggleStar when star clicked', () => { + const onToggleStar = vi.fn(); + render(); const starBtn = screen.getByTitle('Star'); - expect(starBtn).toHaveTextContent('★'); fireEvent.click(starBtn); - // Optimistic update - expect(await screen.findByTitle('Unstar')).toHaveTextContent('★'); - - expect(global.fetch).toHaveBeenCalledWith( - '/api/item/1', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ - _id: 1, - read: false, - starred: true, - }), - }) - ); + expect(onToggleStar).toHaveBeenCalledWith(mockItem); }); it('updates styling when read state changes', () => { const { rerender } = render(); const link = screen.getByText('Test Item'); - // Initial state: unread (bold) - // Note: checking computed style might be flaky in jsdom, but we can check the class on the parent const listItem = link.closest('li'); expect(listItem).toHaveClass('unread'); expect(listItem).not.toHaveClass('read'); - // Update prop to read rerender(); - - // Should now be read expect(listItem).toHaveClass('read'); expect(listItem).not.toHaveClass('unread'); }); - it('loads full content', async () => { + it('loads full content and calls onUpdate', async () => { + const onUpdate = vi.fn(); vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, - json: async () => ({ ...mockItem, full_content: '

Full Content Loaded

' }), + json: async () => ({ full_content: '

Full Content Loaded

' }), } as Response); - render(); + const { rerender } = render(); const scrapeBtn = screen.getByTitle('Load Full Content'); fireEvent.click(scrapeBtn); await waitFor(() => { - expect(screen.getByText('Full Content Loaded')).toBeInTheDocument(); + expect(global.fetch).toHaveBeenCalledWith('/api/item/1', expect.anything()); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ + full_content: '

Full Content Loaded

' + })); }); - expect(global.fetch).toHaveBeenCalledWith('/api/item/1', expect.anything()); + // Simulate parent updating prop + rerender(Full Content Loaded

' }} onUpdate={onUpdate} />); + expect(screen.getByText('Full Content Loaded')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx index ac142dc..865c080 100644 --- a/frontend/src/components/FeedItem.tsx +++ b/frontend/src/components/FeedItem.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, memo } from 'react'; import type { Item } from '../types'; import './FeedItem.css'; @@ -6,54 +6,26 @@ import { apiFetch } from '../utils'; interface FeedItemProps { item: Item; + onToggleStar?: (item: Item) => void; + onUpdate?: (item: Item) => void; } -export default function FeedItem({ item: initialItem }: FeedItemProps) { - const [item, setItem] = useState(initialItem); +const FeedItem = memo(function FeedItem({ item, onToggleStar, onUpdate }: FeedItemProps) { const [loading, setLoading] = useState(false); - useEffect(() => { - setItem(initialItem); - }, [initialItem]); + // We rely on props.item for data. + // If we fetch full content, we notify the parent via onUpdate. - const toggleStar = () => { - updateItem({ ...item, starred: !item.starred }); - }; - - const updateItem = (newItem: Item) => { - setLoading(true); - // Optimistic update - const previousItem = item; - setItem(newItem); - - apiFetch(`/api/item/${newItem._id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - _id: newItem._id, - read: newItem.read, - starred: newItem.starred, - }), - }) - .then((res) => { - if (!res.ok) { - throw new Error('Failed to update item'); - } - return res.json(); - }) - .then(() => { - // Confirm with server response if needed, but for now we trust the optimistic update - // or we could setItem(updated) if the server returns the full object - setLoading(false); - }) - .catch((err) => { - console.error('Error updating item:', err); - // Revert on error - setItem(previousItem); - setLoading(false); - }); + const handleToggleStar = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onToggleStar) { + onToggleStar(item); + } else { + // Fallback if no handler passed (backward compat or isolated usage) + // But really we should rely on parent. + // For now, let's keep the optimistic local update logic if we were standalone, + // but since we are optimizing, we assume parent handles it. + } }; const loadFullContent = (e: React.MouseEvent) => { @@ -65,7 +37,11 @@ export default function FeedItem({ item: initialItem }: FeedItemProps) { return res.json(); }) .then((data) => { - setItem({ ...item, ...data }); + // Merge the new data (full_content) into the item and notify parent + const newItem = { ...item, ...data }; + if (onUpdate) { + onUpdate(newItem); + } setLoading(false); }) .catch((err) => { @@ -81,10 +57,7 @@ export default function FeedItem({ item: initialItem }: FeedItemProps) { {item.title || '(No Title)'}