diff --git a/src/components/ArticleView/ArticleActionBar.tsx b/src/components/ArticleView/ArticleActionBar.tsx index 90e9548..a09d2a2 100644 --- a/src/components/ArticleView/ArticleActionBar.tsx +++ b/src/components/ArticleView/ArticleActionBar.tsx @@ -2,7 +2,7 @@ * ArticleActionBar - Fixed bottom banner with Favorite, Translate, AI Summary, Annotate, and Share buttons. */ -import { useState } from 'react'; +import { useState, useCallback, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Heart, Languages, Sparkles, Loader2, Highlighter, Share2 } from 'lucide-react'; @@ -19,7 +19,7 @@ interface ArticleActionBarProps { onToggleAnnotate: () => void; } -export function ArticleActionBar({ +export const ArticleActionBar = memo(function ArticleActionBar({ isFavorite, isTranslating, isSummarizing, @@ -35,7 +35,7 @@ export function ArticleActionBar({ const [copied, setCopied] = useState(false); const [shareError, setShareError] = useState(false); - const handleShare = async () => { + const handleShare = useCallback(async () => { const url = articleLink || window.location.href; const title = articleTitle || document.title; if (navigator.share) { @@ -58,7 +58,7 @@ export function ArticleActionBar({ setTimeout(() => setShareError(false), 1500); } } - }; + }, [articleLink, articleTitle]); return (
); -} +}); diff --git a/src/pages/ArticleDetailPage.tsx b/src/pages/ArticleDetailPage.tsx index 1273e48..23907b8 100644 --- a/src/pages/ArticleDetailPage.tsx +++ b/src/pages/ArticleDetailPage.tsx @@ -49,6 +49,13 @@ function parseContentSegments(html: string): { html: string; text: string }[] { /** Maximum milliseconds to wait for an AI operation before auto-aborting. */ const AI_OPERATION_TIMEOUT_MS = 60_000; +const ANNOTATION_COLOR_CLASS: Record = { + yellow: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700', + green: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700', + blue: 'bg-blue-100 border-blue-300 dark:bg-blue-900/30 dark:border-blue-700', + pink: 'bg-pink-100 border-pink-300 dark:bg-pink-900/30 dark:border-pink-700', +}; + export function ArticleDetailPage() { const { article: loaderArticle, feed } = useLoaderData() as ArticleDetailLoaderData; const navigate = useNavigate(); @@ -66,6 +73,9 @@ export function ArticleDetailPage() { const [annotationNote, setAnnotationNote] = useState(''); const [annotationColor, setAnnotationColor] = useState('yellow'); const contentRef = useRef(null); + // Keep a ref to the latest article for stable callbacks (avoids stale closures) + const articleRef = useRef(article); + articleRef.current = article; const [translations, setTranslations] = useState>({}); const [translatingIndex, setTranslatingIndex] = useState(-1); const [isTranslating, setIsTranslating] = useState(false); @@ -132,22 +142,23 @@ export function ArticleDetailPage() { // Manual retry: fetch full content from original URL const handleLoadFullContent = useCallback(async () => { - if (!article.link) return; + if (!articleRef.current.link) return; setIsLoadingFullContent(true); setFullContentError(null); try { - const updated = await fetchAndCacheFullContent(article); + const updated = await fetchAndCacheFullContent(articleRef.current); setArticle(updated); } catch { setFullContentError('Failed to load full article content'); } finally { setIsLoadingFullContent(false); } - }, [article]); + }, []); - const sanitizedContent = article.content - ? sanitizeHTML(article.content) - : article.summary || ''; + const sanitizedContent = useMemo( + () => (article.content ? sanitizeHTML(article.content) : article.summary || ''), + [article.content, article.summary], + ); const segments = useMemo(() => parseContentSegments(sanitizedContent), [sanitizedContent]); @@ -294,19 +305,14 @@ export function ArticleDetailPage() { setAnnotations((prev) => prev.filter((a) => a.id !== id)); }, []); - const annotationColorClass: Record = { - yellow: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700', - green: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700', - blue: 'bg-blue-100 border-blue-300 dark:bg-blue-900/30 dark:border-blue-700', - pink: 'bg-pink-100 border-pink-300 dark:bg-pink-900/30 dark:border-pink-700', - }; + const handleBack = useCallback(() => navigate(-1), [navigate]); return (
{/* Navigation */}