Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
1a34e8a42f fix(issue-detail): drop virtualization when deep-linking, restore reliable landing
Virtualization and precise deep-link landing have fundamentally opposed
contracts: virtualization uses estimated heights for off-screen items,
deep-link needs real heights for everything above the target. Three
prior fix attempts (initial scrollToIndex race, settle-by-silence
observer, 3-pass cooperative scroll) all tried to satisfy both in one
path and none fully stabilized — code/image/mermaid-heavy comments
kept drifting the target after first landing.

Split by user intent instead:
- highlightCommentId set (user came from inbox to read a specific
  comment) -> render flat. Every comment mounts, every height is real,
  the target id is in the DOM the instant the effect runs. Native
  document.getElementById + el.scrollIntoView({block:'center'}) is
  semantically identical to a native <a href="#comment-X"> anchor.
- otherwise -> Virtuoso. Browsing mode keeps the first-paint perf win
  from #2413 on long timelines.

Deep-link effect collapses to ~22 lines, matching the pre-virtualization
implementation. A shared renderItem function keeps both render modes
consistent. Removes: bootstrapRef, three-pass scrollToIndex effect,
overflow-anchor:none, scrollPaddingTop on container, scroll-margin-top
on every comment wrapper, virtuosoRef + VirtuosoHandle, initialItemCount
prop, useLayoutEffect.

Mermaid gets a 280px skeleton (web.dev CLS guidance) plus a
sessionStorage layout cache keyed by chart-text hash, so the 0px ->
real-height shift no longer drifts the surrounding layout — useful for
both render modes, deep-link or browsing. Pattern matches ant-design/x
#1497 which fixes the same Mermaid drift in their own stack.

Auto-expand a folded resolved thread when the deep-link target is a
reply inside it; without this the target reply stays collapsed and the
user sees only the resolved-bar.

Net: +131 / -245 in issue-detail.tsx. Tests added for the
resolved-thread-reply auto-expand path.

Known follow-ups:
- <ReadonlyImage> aspect-ratio for image CLS (same class as Mermaid).
- Layout heisenbug (page width "abnormal" without devtools open) is
  orthogonal to deep-link and survives this PR; needs separate triage.
- 500+ comment cold mount in deep-link mode pays full markdown+lowlight
  cost; GitHub takes the same hit and we accept it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:30:24 +08:00
4 changed files with 348 additions and 246 deletions

View File

@@ -15,7 +15,7 @@
* the page.
*/
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { Maximize2 } from "lucide-react";
import { useT } from "../i18n";
@@ -113,6 +113,60 @@ function getMermaidLayout(svg: string): MermaidLayout {
return {};
}
// Default skeleton height while Mermaid loads + renders for the first time
// in this session. Picked to absorb most issue-detail diagrams without
// excessive empty space; web.dev's CLS guidance recommends reserving any
// such space upfront so async content doesn't shift surrounding layout.
const MERMAID_SKELETON_HEIGHT_PX = 280;
const MERMAID_LAYOUT_CACHE_PREFIX = "multica:mermaid:layout:";
// DJB2 — small, fast, sufficient for sessionStorage cache keys. The chart
// text itself is too unwieldy as a key (length, special chars), and a
// crypto-strength hash would have to be async.
function hashChart(chart: string): string {
let hash = 5381;
for (let i = 0; i < chart.length; i++) {
hash = ((hash << 5) + hash) ^ chart.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
function readCachedLayout(chart: string): MermaidLayout | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(
MERMAID_LAYOUT_CACHE_PREFIX + hashChart(chart),
);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed?.width === "number" &&
typeof parsed?.height === "number" &&
parsed.width > 0 &&
parsed.height > 0
) {
return { width: parsed.width, height: parsed.height };
}
return null;
} catch {
return null;
}
}
function writeCachedLayout(chart: string, layout: MermaidLayout): void {
if (typeof window === "undefined") return;
if (!layout.width || !layout.height) return;
try {
window.sessionStorage.setItem(
MERMAID_LAYOUT_CACHE_PREFIX + hashChart(chart),
JSON.stringify({ width: layout.width, height: layout.height }),
);
} catch {
// Quota exceeded or storage disabled — degrade silently; we still
// render correctly, just without the zero-shift optimisation.
}
}
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
@@ -200,7 +254,11 @@ export function MermaidDiagram({ chart }: { chart: string }) {
const themeVersion = useThemeVersion();
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
const [layout, setLayout] = useState<MermaidLayout>({});
// Lazy initial value: if we've rendered this exact chart already in the
// current session, the cached layout lets us reserve correct space on the
// very first paint — eliminating the 0px → real-height shift that breaks
// deep-link scroll positioning and ambient reading position.
const [layout, setLayout] = useState<MermaidLayout>(() => readCachedLayout(chart) ?? {});
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
@@ -212,7 +270,10 @@ export function MermaidDiagram({ chart }: { chart: string }) {
setError(null);
setSandboxedDocument(null);
setExpandedDocument(null);
setLayout({});
// Seed layout from cache (if any) so the skeleton sizes correctly
// even when `chart` changes after mount — the lazy useState above
// only fires once.
setLayout(readCachedLayout(chart) ?? {});
const mermaid = await getMermaid();
mermaid.initialize({
startOnLoad: false,
@@ -222,7 +283,9 @@ export function MermaidDiagram({ chart }: { chart: string }) {
});
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
if (!cancelled) {
setLayout(getMermaidLayout(renderedSvg));
const measured = getMermaidLayout(renderedSvg);
setLayout(measured);
writeCachedLayout(chart, measured);
setSandboxedDocument(
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
);
@@ -255,8 +318,21 @@ export function MermaidDiagram({ chart }: { chart: string }) {
);
}
// While the iframe is not yet ready, hold the container at the skeleton
// height (cached real height when available, fallback default otherwise).
// Once the iframe renders, drop the min-height — the iframe's own height
// drives layout. If the cache was right, this transition is zero-shift.
const containerStyle: CSSProperties | undefined = sandboxedDocument
? undefined
: { minHeight: layout.height ?? MERMAID_SKELETON_HEIGHT_PX };
return (
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
<div
ref={containerRef}
className="mermaid-diagram"
aria-label="Mermaid diagram"
style={containerStyle}
>
{sandboxedDocument ? (
<>
<iframe

View File

@@ -663,7 +663,7 @@ function CommentCardImpl({
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} data-comment-id={reply.id} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<CommentRow
issueId={issueId}
entry={reply}

View File

@@ -287,14 +287,14 @@ vi.mock("@multica/core/issues/stores", () => ({
// Mock react-virtuoso: jsdom has no real layout, so the real Virtuoso would
// compute a 0-height viewport and render nothing. The mock renders every item
// inline, which matches how the unvirtualized .map used to behave and keeps
// existing assertions (`getByText('Started working on this')` etc.) working.
// inline so id="comment-..." nodes are always present in the DOM — this
// matches the production cold-path where `initialItemCount` force-mounts
// items[0..targetIdx], giving the native scrollIntoView a real target.
//
// scrollToIndexSpy: the deep-link logic now uses Virtuoso's own
// scrollToIndex API instead of native el.scrollIntoView (native diverges
// from Virtuoso's internal scrollTop model, petyosi #1083). The mock
// exposes a spy so we can assert deep-link landing in tests.
const scrollToIndexSpy = vi.hoisted(() => vi.fn());
// scrollIntoViewSpy: we spy on Element.prototype.scrollIntoView (jsdom no-ops
// it by default) so tests can assert the deep-link effect dispatched a
// native scroll on the target node.
const scrollIntoViewSpy = vi.hoisted(() => vi.fn());
vi.mock("react-virtuoso", () => ({
Virtuoso: forwardRef(function MockVirtuoso(
@@ -302,9 +302,10 @@ vi.mock("react-virtuoso", () => ({
ref: any,
) {
useImperativeHandle(ref, () => ({
scrollToIndex: scrollToIndexSpy,
// Real Virtuoso exposes more, but the deep-link path only needs
// scrollToIndex. Other call sites would fail loudly if added later.
// Real Virtuoso ref methods are not exercised by tests in this file
// since the cold-path uses native scrollIntoView on the DOM node.
scrollIntoView: vi.fn(),
scrollToIndex: vi.fn(),
}));
return (
<div data-testid="virtuoso-mock">
@@ -316,6 +317,17 @@ vi.mock("react-virtuoso", () => ({
}),
}));
// jsdom's HTMLElement.prototype.scrollIntoView is a no-op stub; replace it
// with a spy so the deep-link effect's call can be observed.
beforeEach(() => {
scrollIntoViewSpy.mockClear();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
writable: true,
value: scrollIntoViewSpy,
});
});
// Mock modals
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
@@ -597,30 +609,26 @@ describe("IssueDetail (shared)", () => {
});
describe("highlightCommentId scroll-to-comment", () => {
beforeEach(() => {
scrollToIndexSpy.mockClear();
});
it("scrolls to the highlighted comment after both issue and timeline finish loading", async () => {
renderIssueDetailWithHighlight("comment-2");
// Wait for the comment row to mount under the virtuoso mock.
// Wait for the comment row to mount. With initialItemCount in
// production, items[0..targetIdx] are force-mounted on first commit;
// the mock unconditionally inline-renders every item, so this just
// waits for the regular render pass.
await waitFor(() => {
expect(
document.querySelector('[data-comment-id="comment-2"]'),
document.getElementById("comment-comment-2"),
).not.toBeNull();
});
// The deep-link effect calls virtuosoRef.scrollToIndex with the
// target's index. comment-2 is items[1] in the flat timeline
// (items[0] = comment-1, items[1] = comment-2). The effect calls
// scrollToIndex twice (once on enter, once after the settle
// timeout); we only need to see at least one call land.
// The deep-link useLayoutEffect calls native scrollIntoView on the
// target node ({block: 'center'}).
await waitFor(() => {
expect(scrollToIndexSpy).toHaveBeenCalled();
expect(scrollIntoViewSpy).toHaveBeenCalled();
});
expect(scrollToIndexSpy).toHaveBeenCalledWith(
expect.objectContaining({ index: 1, align: "center" }),
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
expect.objectContaining({ block: "center" }),
);
});
@@ -628,9 +636,8 @@ describe("IssueDetail (shared)", () => {
// Reproduces the inbox-click race: timeline data is in the cache
// before the issue resolves. While loading is true, IssueDetail
// renders the loading skeleton (Virtuoso never mounts), so no
// scrollToIndex can fire. After the issue resolves, Virtuoso
// mounts, the bootstrapRef capture path or the warm-path effect
// fires scrollToIndex with the target index.
// scroll can fire. After the issue resolves, Virtuoso mounts and
// the useLayoutEffect dispatches the native scroll.
let resolveIssue: (value: Issue) => void = () => {};
const issuePromise = new Promise<Issue>((resolve) => {
resolveIssue = resolve;
@@ -640,20 +647,77 @@ describe("IssueDetail (shared)", () => {
renderIssueDetailWithHighlight("comment-2", "issue-1", { seedTimeline: true });
expect(
document.querySelector('[data-comment-id="comment-2"]'),
document.getElementById("comment-comment-2"),
).toBeNull();
expect(scrollToIndexSpy).not.toHaveBeenCalled();
expect(scrollIntoViewSpy).not.toHaveBeenCalled();
resolveIssue(mockIssue);
await waitFor(() => {
expect(
document.querySelector('[data-comment-id="comment-2"]'),
document.getElementById("comment-comment-2"),
).not.toBeNull();
});
await waitFor(() => {
expect(scrollToIndexSpy).toHaveBeenCalledWith(
expect.objectContaining({ index: 1, align: "center" }),
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
expect.objectContaining({ block: "center" }),
);
});
});
it("auto-expands a folded resolved thread when deep-link target is a reply inside it", async () => {
// Seed a timeline where comment-3 is resolved (so it renders as a
// resolved-bar by default) and has a reply, reply-1, whose id is the
// deep-link target. The reply is not in the flat items array — only
// the resolved-bar root is. The effect must detect this, expand the
// thread, then on re-run scroll to the reply's id="comment-reply-1" node.
const timelineWithResolvedThread: TimelineEntry[] = [
...mockTimeline,
{
type: "comment",
id: "comment-3",
actor_type: "member",
actor_id: "user-1",
content: "Resolved root",
parent_id: null,
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
comment_type: "comment",
resolved_at: "2026-01-19T00:00:00Z",
} as TimelineEntry,
{
type: "comment",
id: "reply-1",
actor_type: "member",
actor_id: "user-1",
content: "Reply inside resolved thread",
parent_id: "comment-3",
created_at: "2026-01-18T01:00:00Z",
updated_at: "2026-01-18T01:00:00Z",
comment_type: "comment",
} as TimelineEntry,
];
mockApiObj.listTimeline.mockResolvedValue(timelineWithResolvedThread);
const queryClient = createTestQueryClient();
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<IssueDetail issueId="issue-1" highlightCommentId="reply-1" />
</QueryClientProvider>
</I18nProvider>,
);
// After expansion, the reply must appear in the DOM (inside the now
// -unfolded CommentCard) and the deep-link effect must scroll to it.
await waitFor(() => {
expect(
document.getElementById("comment-reply-1"),
).not.toBeNull();
});
await waitFor(() => {
expect(scrollIntoViewSpy).toHaveBeenCalledWith(
expect.objectContaining({ block: "center" }),
);
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from "react";
import { Virtuoso } from "react-virtuoso";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
@@ -399,7 +399,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// Virtuoso prop would never receive the element. Callback ref + state fixes
// that: setState triggers the re-render that hands Virtuoso the element.
const [scrollContainerEl, setScrollContainerEl] = useState<HTMLDivElement | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
// Per-session: which resolved threads the user has temporarily expanded.
@@ -668,83 +667,44 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const loading = issueLoading;
// First-paint deep-link bootstrap. Captured exactly once, on the first
// render where items[] is populated. Why a ref rather than a lazy
// useState initializer: IssueDetail mounts long before timeline data
// resolves (items.length is 0), so a lazy useState would freeze in
// the "no bootstrap" state forever. The ref follows React's documented
// "avoid recreating ref contents" idiom (the Video example in the
// useRef docs): write synchronously in render, gated by a one-shot
// flag so the value can't be overwritten on later renders.
// Deep-link landing. Semantically equivalent to navigating to
// `#comment-${id}`: find the element with that id, scrollIntoView it.
// When `highlightCommentId` is set the timeline below renders flat (no
// virtualization), so every comment id is in the DOM by the time this
// effect runs after commit.
//
// Passed to <Virtuoso initialTopMostItemIndex>: the only position
// anchor that runs *before* first paint, dodging the post-mount
// scrollToIndex race (Virtuoso #883). Subsequent deep-links inside
// the same mount (user clicks a second inbox notification on the
// same issue) go through scrollToIndex in the effect below — the
// bootstrap value is only consumed on cold start.
const bootstrapRef = useRef<{
resolved: boolean;
value?: { index: number; align: "center" };
}>({ resolved: false });
if (!bootstrapRef.current.resolved && items.length > 0) {
bootstrapRef.current = {
resolved: true,
value:
highlightCommentId && targetIdx >= 0
? { index: targetIdx, align: "center" }
: undefined,
};
}
const initialBootstrap = bootstrapRef.current.value;
// Deep-link landing. Virtualization makes "land precisely" not a
// single operation but a convergence: Virtuoso first uses estimated
// spacer heights to scroll to the target, mounts viewport items, the
// ResizeObserver fires real measurements, spacer heights update, and
// scrollTop is corrected. Markdown render and lowlight code highlight
// can reflow items again later in the same frame, triggering another
// round of correction. Trying to outsmart this with a single
// perfectly-timed scroll is what made the previous two attempts both
// complex and unreliable.
// For a reply inside a folded resolved thread, the reply is not in items
// (only the resolved-bar root is). Auto-expand the thread first; the
// effect re-runs once items re-flatten.
//
// This effect cooperates with Virtuoso's correction loop instead of
// fighting it: schedule three scrollToIndex calls — immediate, 120ms
// (after the first measurement pass), 500ms (after markdown/lowlight
// settle). Each call uses whatever spacer heights are current, so
// the convergence narrows on each pass. Visually this is a single
// instant scroll with at most a few pixels of late re-centering —
// not a re-jump, because each pass starts from the previous result.
//
// virtuosoRef.scrollToIndex (not native el.scrollIntoView) keeps
// Virtuoso's internal scrollTop model consistent (petyosi #1083).
// `scrollContainerEl` is in deps because the component early-returns a
// loading skeleton while the issue query is pending. The scroll-container
// ref populates only on the post-loading render, so it's the signal that
// the timeline (and the deep-link target id) has actually rendered.
useEffect(() => {
if (!highlightCommentId || items.length === 0 || targetIdx < 0) return;
if (!scrollContainerEl) return;
if (!highlightCommentId || items.length === 0) return;
if (didHighlightRef.current === highlightCommentId) return;
const rootId = replyToRoot.get(highlightCommentId);
if (
rootId &&
rootId !== highlightCommentId &&
items[targetIdx]?.kind === "resolved-bar"
) {
toggleResolvedExpand(rootId, true);
return;
}
const el = document.getElementById(`comment-${highlightCommentId}`);
if (!el) return;
didHighlightRef.current = highlightCommentId;
const land = () =>
virtuosoRef.current?.scrollToIndex({
index: targetIdx,
align: "center",
behavior: "auto",
});
land();
const t1 = window.setTimeout(land, 120);
const t2 = window.setTimeout(land, 500);
el.scrollIntoView({ block: "center" });
setHighlightedId(highlightCommentId);
const fade = window.setTimeout(() => setHighlightedId(null), 2500);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(fade);
};
}, [highlightCommentId, items.length, targetIdx, scrollContainerEl]);
return () => clearTimeout(fade);
}, [highlightCommentId, items, targetIdx, scrollContainerEl, replyToRoot, toggleResolvedExpand]);
// Cmd-F / Ctrl-F on a virtualized timeline only searches what's mounted in
// the viewport — off-screen comments are invisible to browser find-in-page.
@@ -979,6 +939,97 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
);
// Shared row renderer for both timeline render modes (flat / virtualized).
// The wrapper `id="comment-..."` is the deep-link target — equivalent to
// a native `<a href="#comment-...">` anchor.
const renderItem = (_i: number, item: TimelineItem): React.ReactElement => {
if (item.kind === "resolved-bar") {
return (
<div className="pb-3" id={`comment-${item.id}`}>
<ResolvedThreadBar
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
onExpand={() => toggleResolvedExpand(item.id, true)}
/>
</div>
);
}
if (item.kind === "comment") {
const isResolved = !!item.entry.resolved_at;
return (
<div className="pb-3" id={`comment-${item.id}`}>
<CommentCard
issueId={id}
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
onResolveToggle={handleResolveToggle}
onCollapseResolved={isResolved ? () => toggleResolvedExpand(item.id, false) : undefined}
highlightedCommentId={highlightedId}
/>
</div>
);
}
// activity-group
return (
<div className="pb-3 px-4 flex flex-col gap-3">
{item.entries.map((entry) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
);
};
const detailContent = (
<div className="flex h-full min-w-0 flex-1 flex-col">
<PageHeader className="gap-2 bg-background text-sm">
@@ -1092,16 +1143,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
</PageHeader>
{/* Content — scrollable. `overflow-anchor: none` disables the
browser's built-in scroll-anchoring so that late layout shifts
inside the virtualized timeline (Virtuoso resizing list items
above the viewport, images inside comments resolving their
natural size, etc.) don't silently nudge scrollTop and undo
the deep-link scroll we just performed. */}
<div
ref={setScrollContainerEl}
className="relative flex-1 overflow-y-auto"
style={{ overflowAnchor: "none" }}
>
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<TitleEditor
@@ -1364,137 +1408,55 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
miscomputes total-height on first paint. */}
{timelineLoading && timelineView.groups.length === 0 ? (
<TimelineSkeleton />
) : !scrollContainerEl ? (
// Show skeleton (not blank) while the callback ref populates,
// so the gap between IssueDetail mount and Virtuoso mount feels
// continuous with the loading state instead of flashing empty.
<TimelineSkeleton />
) : (
<div className="mt-4">
<Virtuoso
key={`${wsId}:${id}`}
ref={virtuosoRef}
customScrollParent={scrollContainerEl}
data={items}
increaseViewportBy={{ top: 800, bottom: 800 }}
computeItemKey={(_i, item) => `${item.kind}:${item.id}`}
skipAnimationFrameInResizeObserver
// First-paint anchor for inbox deep-link. Only ever
// populated on initial mount (see `initialBootstrap` /
// `bootstrapRef` above); subsequent re-renders pass the
// same value, so Virtuoso #458 (this prop acting as a
// persistent anchor that resets scrollTop on height
// changes) doesn't fire. Warm-path deep-links — user
// clicks a second inbox notification on the same issue
// — go through scrollToIndex in the effect above.
//
// Spread-on-defined: passing `initialTopMostItemIndex
// ={undefined}` triggers a runtime crash inside
// react-virtuoso ("Cannot read properties of undefined
// (reading 'index')") because the library accesses
// `.index` on the prop without a null guard. Omitting
// the prop entirely takes the library's default path.
{...(initialBootstrap && { initialTopMostItemIndex: initialBootstrap })}
// followOutput intentionally NOT set. Virtuoso treats it as
// a sticky "is at bottom" flag and resets scrollTop to
// maxScrollTop on every ResizeObserver / height-change tick
// — this is what was yanking the user back to scrollTop=299
// whenever they tried to scroll up after a deep-link
// landed on the last item. Issue-detail is document-shaped
// (not a chat), so auto-follow on new comments is not
// critical; users can scroll to bottom themselves.
itemContent={(_i, item) => {
if (item.kind === "resolved-bar") {
return (
// data-comment-id retained for any external code
// (tests, debugging, future deep-link variants)
// that wants to find a comment node directly.
<div className="pb-3" data-comment-id={item.id}>
<ResolvedThreadBar
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
onExpand={() => toggleResolvedExpand(item.id, true)}
/>
</div>
);
}
if (item.kind === "comment") {
const isResolved = !!item.entry.resolved_at;
return (
<div className="pb-3" data-comment-id={item.id}>
<CommentCard
issueId={id}
entry={item.entry}
replies={timelineView.threadReplies.get(item.id) ?? EMPTY_REPLIES}
currentUserId={user?.id}
canModerate={canModerateComments}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
onResolveToggle={handleResolveToggle}
onCollapseResolved={isResolved ? () => toggleResolvedExpand(item.id, false) : undefined}
highlightedCommentId={highlightedId}
/>
</div>
);
}
// activity-group
return (
<div className="pb-3 px-4 flex flex-col gap-3">
{item.entries.map((entry) => {
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, t, getActorName)}</span>
{(entry.coalesced_count ?? 1) > 1 &&
entry.action !== "task_completed" &&
entry.action !== "task_failed" && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t(($) => $.activity.coalesced_badge, { count: entry.coalesced_count ?? 1 })}
</span>
)}
<Tooltip>
<TooltipTrigger
render={
<span className="ml-auto shrink-0 cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
);
}}
/>
</div>
// Two render modes:
// - `highlightCommentId` set (came from inbox deep-link) →
// render flat. Every comment mounts, every height is real,
// the target id is in the DOM the instant the useEffect
// above runs `scrollIntoView`. No virtualization estimate
// errors, no spacer reflow drift. Pays cold-mount cost
// proportional to items.length (markdown + lowlight per
// comment), which is acceptable in the deep-link case —
// the user has explicit intent to land on a specific item.
// - otherwise → Virtuoso. Browsing mode, virtualization
// wins on first-paint perf for long timelines.
//
// The split is deliberate: virtualization and "land precisely
// on a target" have fundamentally opposed contracts (estimated
// heights vs real heights). Trying to satisfy both in one
// path is what produced the bug history this PR closes.
!highlightCommentId ? (
!scrollContainerEl ? (
// Skeleton while the callback ref populates so the gap
// between IssueDetail mount and Virtuoso mount doesn't
// flash empty.
<TimelineSkeleton />
) : (
<div className="mt-4">
<Virtuoso
key={`${wsId}:${id}`}
customScrollParent={scrollContainerEl}
data={items}
increaseViewportBy={{ top: 800, bottom: 800 }}
computeItemKey={(_i, item) => `${item.kind}:${item.id}`}
skipAnimationFrameInResizeObserver
// followOutput intentionally NOT set. Virtuoso treats
// it as a sticky "is at bottom" flag and resets
// scrollTop to maxScrollTop on every height-change
// tick — issue-detail is document-shaped, not chat.
itemContent={renderItem}
/>
</div>
)
) : (
<div className="mt-4">
{items.map((item, i) => (
<Fragment key={`${item.kind}:${item.id}`}>
{renderItem(i, item)}
</Fragment>
))}
</div>
)
)}
{/* Bottom comment input — no avatar, full width */}