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')
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)'}