From fa2641bdedef25bab9598f72a3a2761cb6980738 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Mar 2026 16:25:01 +0000
Subject: [PATCH 1/6] Initial plan
From ceb4265120cd0262d71c25ed01de93a6d27da14e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 4 Mar 2026 16:30:06 +0000
Subject: [PATCH 2/6] perf: optimize ArticleDetailPage and ArticleActionBar
rendering performance
- Memoize sanitizedContent with useMemo (prevents DOMPurify on every render)
- Wrap ArticleActionBar with React.memo (prevents re-renders during streaming)
- Move annotationColorClass to module-level constant
- Use articleRef pattern for handleLoadFullContent to avoid stale closures
- Memoize handleShare with useCallback in ArticleActionBar
- Memoize back navigation handler with useCallback
Co-authored-by: chiga0 <24784430+chiga0@users.noreply.github.com>
---
.../ArticleView/ArticleActionBar.tsx | 10 +++---
src/pages/ArticleDetailPage.tsx | 33 +++++++++++--------
2 files changed, 24 insertions(+), 19 deletions(-)
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..f50e497 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,8 @@ export function ArticleDetailPage() {
const [annotationNote, setAnnotationNote] = useState('');
const [annotationColor, setAnnotationColor] = useState('yellow');
const contentRef = useRef(null);
+ const articleRef = useRef(article);
+ articleRef.current = article;
const [translations, setTranslations] = useState>({});
const [translatingIndex, setTranslatingIndex] = useState(-1);
const [isTranslating, setIsTranslating] = useState(false);
@@ -132,22 +141,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 +304,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 */}