From 38e7062b3db6c605359971693702229a2d8188bf Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Fri, 1 May 2026 09:08:06 +0200 Subject: [PATCH] fix(views): prevent browser freeze on long-timeline issues in Inbox Three changes to address the performance freeze when opening issues with thousands of timeline entries from the Inbox: 1. Truncate initial timeline to ~50 most recent groups with a "Show earlier entries" button. When a highlightCommentId is provided (e.g. from Inbox click), the full timeline is expanded automatically so scroll-to-comment still works. 2. Wrap CommentCard with React.memo so parent re-renders (from Inbox WS events, state changes in IssueDetail) skip reconciliation of unchanged comment trees. 3. Wrap ReadonlyContent with React.memo so the heavy markdown pipeline (react-markdown + rehype-raw + rehype-sanitize + rehype-katex + lowlight) is skipped when content/className haven't changed. Closes #1968 Co-authored-by: multica-agent --- packages/views/editor/readonly-content.tsx | 6 ++-- .../views/issues/components/comment-card.tsx | 6 ++-- .../views/issues/components/issue-detail.tsx | 32 +++++++++++++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx index 1a28592aeb..de46a73738 100644 --- a/packages/views/editor/readonly-content.tsx +++ b/packages/views/editor/readonly-content.tsx @@ -16,7 +16,7 @@ * - Rendering mentions with the same IssueMentionCard component and .mention class */ -import { isValidElement, useEffect, useId, useMemo, useRef, useState } from "react"; +import { isValidElement, memo, useEffect, useId, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import ReactMarkdown, { defaultUrlTransform, @@ -573,7 +573,7 @@ interface ReadonlyContentProps { className?: string; } -export function ReadonlyContent({ content, className }: ReadonlyContentProps) { +export const ReadonlyContent = memo(function ReadonlyContent({ content, className }: ReadonlyContentProps) { const processed = useMemo(() => preprocessMarkdown(content), [content]); const wrapperRef = useRef(null); const hover = useLinkHover(wrapperRef); @@ -591,4 +591,4 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) { ); -} +}); diff --git a/packages/views/issues/components/comment-card.tsx b/packages/views/issues/components/comment-card.tsx index 8e086caa80..d8d619d6d9 100644 --- a/packages/views/issues/components/comment-card.tsx +++ b/packages/views/issues/components/comment-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { memo, useCallback, useRef, useState } from "react"; import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@multica/ui/components/ui/card"; @@ -348,7 +348,7 @@ function CommentRow({ // CommentCard — One Card per thread (parent + all replies flat inside) // --------------------------------------------------------------------------- -function CommentCard({ +const CommentCard = memo(function CommentCard({ issueId, entry, allReplies, @@ -604,6 +604,6 @@ function CommentCard({ ); -} +}); export { CommentCard, type CommentCardProps }; diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 25526aaf7e..77c23fc32e 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -193,6 +193,8 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr const scrollContainerRef = useRef(null); const [highlightedId, setHighlightedId] = useState(null); const didHighlightRef = useRef(null); + const [showAllTimeline, setShowAllTimeline] = useState(false); + const INITIAL_VISIBLE_GROUPS = 50; // Issue data from TQ — uses detail query, seeded from list cache if available. // Only seed when description is present; list API omits it, and ContentEditor @@ -276,6 +278,12 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr const loading = issueLoading; + // When a highlight target is specified, expand the full timeline so the + // comment is guaranteed to be rendered before we attempt to scroll to it. + useEffect(() => { + if (highlightCommentId) setShowAllTimeline(true); + }, [highlightCommentId]); + // Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId) useEffect(() => { if (!highlightCommentId || timeline.length === 0) return; @@ -924,7 +932,12 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr } } - return groups.map((group) => { + // Truncate: show only the most recent groups initially + const isTruncated = !showAllTimeline && groups.length > INITIAL_VISIBLE_GROUPS; + const hiddenCount = isTruncated ? groups.length - INITIAL_VISIBLE_GROUPS : 0; + const visibleGroups = isTruncated ? groups.slice(-INITIAL_VISIBLE_GROUPS) : groups; + + const renderGroup = (group: typeof groups[number]) => { if (group.type === "comment") { const entry = group.entries[0]!; return ( @@ -990,7 +1003,22 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr })} ); - }); + }; + + return ( + <> + {isTruncated && ( + + )} + {visibleGroups.map(renderGroup)} + + ); })()}