Files
multica/packages/views/issues/components/resolved-thread-bar.tsx
Bohan Jiang 807201086c perf(issues): stop full timeline re-render on every WS event (#2329)
* perf(issues): stop full timeline re-render on every WS event (MUL-1941)

Two compounding causes made every Comment/reply WS event re-render every
sibling thread on the issue detail page — visible during AI streaming as
a flash across all 10 nested replies under a parent and as the green
reply-input losing its draft.

1) `useCreateComment.onSettled` invalidated the timeline query, forcing a
   full `GET /timeline` refetch on every comment submit. The response
   replaced every entry's reference even when the content was unchanged,
   poisoning every downstream React.memo. The `comment:created` WS
   broadcast already keeps the cache fresh and `useWSReconnect` invalidates
   on disconnect, so the redundant refetch had no upside. Drop it.

2) The `timelineView` useMemo passed the full `repliesByParent: Map` to
   every CommentCard. Each WS event rebuilt the Map (new ref), so React.memo
   on CommentCard fell back to a re-render for *every* card, not just the
   one whose thread changed. Replace the Map prop with a per-thread
   `replies: TimelineEntry[]` slice, precomputed once via
   `collectThreadReplies` and stabilized against the prior render — when a
   thread's flat list is shallow-equal to last time, reuse the previous
   array reference so unrelated cards keep their memo.

ResolvedThreadBar gets the same `replies` prop, so the collapsed count +
author list still match the expanded view without re-walking the graph.

Verified: pnpm typecheck + pnpm test for @multica/views and @multica/core
(334 + 214 tests, all passing).

Co-authored-by: multica-agent <github@multica.ai>

* fix(realtime): mark timeline stale without refetching active queries (MUL-1941)

Per GPT-Boy's review on PR #2329: dropping `useCreateComment.onSettled`'s
invalidate wasn't enough. The global `useRealtimeSync` runs in WSProvider
for the lifetime of the app and re-invalidates the timeline on every
`comment:created` / `comment:updated` / `comment:deleted` /
`comment:resolved` / `comment:unresolved` / `activity:created` /
`reaction:added` / `reaction:removed` event. With `staleTime: Infinity` on
the QueryClient default, the active timeline query refetches on every
invalidate — replacing every entry's reference and busting the per-thread
memoization the prior commit just put in place.

Switch the global handler's `invalidateQueries` to `refetchType: "none"`.
Active observers now stay fresh via the granular `setQueryData` handlers
in `useIssueTimeline`; inactive issues' caches are still marked stale, so
when IssueDetail mounts later, `refetchOnMount` triggers a fresh fetch
the same way it did before.

`comment:resolved` / `comment:unresolved` previously had no granular
handler — only the global invalidate kept the cache in sync. Add
useWSEvent handlers in `useIssueTimeline` that replace the matching
entry via `commentToTimelineEntry`, and extend that helper to carry the
resolved_at / resolved_by_type / resolved_by_id fields so resolved state
survives the round-trip (it was silently dropped on every
`comment:updated` too — fixed as a side effect).

Tests: 3 new cases covering resolved / unresolved / cross-issue isolation
in the timeline hook. All 337 + 214 unit tests + full monorepo typecheck
pass.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 17:20:08 +08:00

63 lines
2.3 KiB
TypeScript

import { CheckCircle2, ChevronRight } from "lucide-react";
import { useActorName } from "@multica/core/workspace/hooks";
import { Card } from "@multica/ui/components/ui/card";
import type { TimelineEntry } from "@multica/core/types";
import { useT } from "../../i18n";
interface ResolvedThreadBarProps {
/** The resolved root comment. */
entry: TimelineEntry;
/**
* Flat list of every nested reply under this thread root. Precomputed by
* `issue-detail.tsx`'s `timelineView` from the same walk that CommentCard
* uses, so the count + author list match what the expanded view renders
* (direct-children-only would undercount nested replies).
*/
replies: TimelineEntry[];
onExpand: () => void;
}
const MAX_NAMED_AUTHORS = 2;
export function ResolvedThreadBar({ entry, replies, onExpand }: ResolvedThreadBarProps) {
const { t } = useT("issues");
const { getActorName } = useActorName();
const authorKeys = new Set<string>();
const authors: Array<{ type: string; id: string }> = [];
for (const e of [entry, ...replies]) {
const key = `${e.actor_type}:${e.actor_id}`;
if (authorKeys.has(key)) continue;
authorKeys.add(key);
authors.push({ type: e.actor_type, id: e.actor_id });
}
const count = 1 + replies.length;
let authorsLabel: string;
if (authors.length <= MAX_NAMED_AUTHORS) {
authorsLabel = authors.map((a) => getActorName(a.type, a.id)).join(", ");
} else {
const named = authors.slice(0, MAX_NAMED_AUTHORS).map((a) => getActorName(a.type, a.id)).join(", ");
const remaining = authors.length - MAX_NAMED_AUTHORS;
authorsLabel = t(($) => $.comment.resolve.bar_authors_more, { names: named, count: remaining });
}
return (
<Card className="!py-0 !gap-0 overflow-hidden">
<button
type="button"
onClick={onExpand}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50 transition-colors"
>
<span className="flex items-center gap-2.5 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="truncate">
{t(($) => $.comment.resolve.bar, { count, authors: authorsLabel })}
</span>
</span>
<ChevronRight className="h-3.5 w-3.5 rotate-90 shrink-0 text-muted-foreground" />
</button>
</Card>
);
}