Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan
38e7062b3d 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 <github@multica.ai>
2026-05-01 09:08:06 +02:00
3 changed files with 36 additions and 8 deletions

View File

@@ -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<HTMLDivElement>(null);
const hover = useLinkHover(wrapperRef);
@@ -591,4 +591,4 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
<LinkHoverCard {...hover} />
</div>
);
}
});

View File

@@ -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({
</Collapsible>
</Card>
);
}
});
export { CommentCard, type CommentCardProps };

View File

@@ -193,6 +193,8 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const didHighlightRef = useRef<string | null>(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
})}
</div>
);
});
};
return (
<>
{isTruncated && (
<button
type="button"
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setShowAllTimeline(true)}
>
Show {hiddenCount} earlier {hiddenCount === 1 ? "entry" : "entries"}
</button>
)}
{visibleGroups.map(renderGroup)}
</>
);
})()}
</div>