Skip to content

Commit aa0b3e2

Browse files
perf(web): the pull request timeline mounts only the events near the viewport (#27795)
Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent 80a2351 commit aa0b3e2

4 files changed

Lines changed: 172 additions & 66 deletions

File tree

‎apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -417,16 +417,16 @@ export function PullRequestDetailPanel({
417417
Partial<Record<PullRequestDetailTab, boolean>>
418418
>({});
419419
const condensed = condensedByTab[tab] ?? false;
420-
const scrollerByTab = useRef<Partial<Record<PullRequestDetailTab, HTMLDivElement | null>>>({});
420+
const scrollerByTab = useRef<Partial<Record<PullRequestDetailTab, HTMLElement | null>>>({});
421421
// Fresh ref callbacks would detach and reattach every scroller on each render.
422422
const [scrollerRefs] = useState(() => ({
423-
summary: (node: HTMLDivElement | null) => {
423+
summary: (node: HTMLElement | null) => {
424424
scrollerByTab.current.summary = node;
425425
},
426-
timeline: (node: HTMLDivElement | null) => {
426+
timeline: (node: HTMLElement | null) => {
427427
scrollerByTab.current.timeline = node;
428428
},
429-
code: (node: HTMLDivElement | null) => {
429+
code: (node: HTMLElement | null) => {
430430
scrollerByTab.current.code = node;
431431
},
432432
}));
@@ -436,7 +436,7 @@ export function PullRequestDetailPanel({
436436
// so applying a delta afterward would compensate twice.
437437
const compensationRef = useRef<{
438438
tab: PullRequestDetailTab;
439-
scroller: HTMLDivElement;
439+
scroller: HTMLElement;
440440
target: number;
441441
} | null>(null);
442442
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the scroller's scrollTop before paint after the header condenses
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
EnvironmentId,
3+
ProjectId,
4+
SourceControlProviderKind,
5+
type PullRequestDetailView,
6+
} from "@t3tools/contracts";
7+
import { expect, it } from "vite-plus/test";
8+
9+
import { render } from "../../../test/render";
10+
import "../../index.css";
11+
import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
12+
13+
const COMMITS = 2000;
14+
15+
const detail: PullRequestDetailView = {
16+
provider: SourceControlProviderKind.make("github"),
17+
projectId: ProjectId.make("project"),
18+
projectTitle: "Project",
19+
workspaceRoot: "/workspace",
20+
repository: "owner/repo",
21+
number: 1,
22+
title: "Huge pull request",
23+
body: "",
24+
url: "https://github.com/owner/repo/pull/1",
25+
author: { login: "author", name: null, avatarUrl: null },
26+
viewer: "author",
27+
state: "open",
28+
isDraft: false,
29+
mergeability: "mergeable",
30+
additions: 1,
31+
deletions: 0,
32+
changedFiles: 1,
33+
headBranch: "feature",
34+
baseBranch: "main",
35+
createdAt: "2026-09-01T00:00:00Z",
36+
updatedAt: "2026-09-01T00:00:00Z",
37+
mergedAt: null,
38+
closedAt: null,
39+
reviewers: [],
40+
labels: [],
41+
checks: [],
42+
comments: [],
43+
commentCount: 0,
44+
commentsTruncated: false,
45+
reviewThreads: [],
46+
commits: Array.from({ length: COMMITS }, (_, index) => ({
47+
oid: `${index.toString(16).padStart(7, "0")}${"0".repeat(33)}`,
48+
messageHeadline: `Commit number ${index}`,
49+
committedDate: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(),
50+
})),
51+
mergeCapabilities: { merge: false, squash: false, rebase: false },
52+
capabilities: {
53+
diff: true,
54+
comment: true,
55+
search: true,
56+
actions: [],
57+
mergeMethods: [],
58+
review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
59+
reviewers: { request: false, listCandidates: false },
60+
edit: { changeRequest: false, comment: false },
61+
},
62+
viewerPermissions: {
63+
actions: [],
64+
comment: false,
65+
resolve: false,
66+
verdicts: [],
67+
requestReviewers: false,
68+
},
69+
};
70+
71+
it("mounts only the timeline rows near the viewport of a huge pull request", async () => {
72+
await using screen = await render(
73+
<div style={{ height: 600 }}>
74+
<PullRequestTimelineTab
75+
scrollerRef={() => {}}
76+
detail={detail}
77+
environmentId={EnvironmentId.make("environment")}
78+
reference={detail}
79+
order="oldest"
80+
onOpenCommit={() => {}}
81+
onRefresh={() => {}}
82+
/>
83+
</div>,
84+
);
85+
await expect.element(screen.getByText("Commit number 0")).toBeVisible();
86+
const mounted = screen.container.querySelectorAll("button").length;
87+
expect(mounted).toBeGreaterThan(0);
88+
expect(mounted).toBeLessThan(COMMITS / 10);
89+
});

‎apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx‎

Lines changed: 77 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3434
import {
3535
buildPullRequestTimeline,
3636
groupPullRequestTimelineConversations,
37+
type PullRequestTimelineRow,
3738
isPullRequestVerdictStale,
3839
newestPullRequestCommitAt,
3940
pullRequestReviewOutcome,
@@ -56,6 +57,7 @@ import {
5657
pullRequestReviewOutcomeToneClassName,
5758
} from "./pullRequestPresentation";
5859
import { PullRequestGlyph } from "./pullRequestIcons";
60+
import { LegendList, type LegendListRef } from "@legendapp/list/react";
5961

6062
interface ReactionSurface {
6163
readonly canReact: boolean;
@@ -634,7 +636,7 @@ export function PullRequestTimelineTab({
634636
onOpenCommit,
635637
onRefresh,
636638
}: {
637-
scrollerRef: (node: HTMLDivElement | null) => void;
639+
scrollerRef: (node: HTMLElement | null) => void;
638640
detail: PullRequestDetailView;
639641
environmentId: EnvironmentId;
640642
threadRef?: ScopedThreadRef | null;
@@ -666,66 +668,81 @@ export function PullRequestTimelineTab({
666668
void readLocalApi()?.shell.openExternal(url);
667669
};
668670

669-
return (
670-
<div ref={scrollerRef} className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5">
671-
<div className="mx-auto max-w-3xl">
672-
<div className="relative">
673-
<span aria-hidden className="absolute bottom-5 left-[15px] top-1 w-px bg-border/45" />
674-
{rows.map((row) => {
675-
if (row.kind === "comments") {
676-
return (
677-
<ConversationGroup
678-
key={`comments:${row.events[0]?.id ?? "empty"}`}
679-
events={row.events}
680-
editable={editable}
681-
cwd={detail.workspaceRoot}
682-
onOpen={openOnHost}
683-
reactions={reactions}
684-
/>
685-
);
686-
}
687-
const event = row.event;
688-
if (event.thread !== undefined) {
689-
return (
690-
<ReviewThreadEvent
691-
key={`${reactions.reference.projectId}#${reactions.reference.number}:thread:${event.id}:${event.thread.isResolved}:${pullRequestReviewOutcome(event.reviewState) === "dismissed"}`}
692-
event={event}
693-
thread={event.thread}
694-
editable={editable}
695-
cwd={detail.workspaceRoot}
696-
onOpen={openOnHost}
697-
reactions={reactions}
698-
/>
699-
);
700-
}
701-
if (event.kind === "commit") {
702-
return <CommitEvent key={event.id} event={event} onOpen={onOpenCommit} />;
703-
}
704-
const outcome = pullRequestReviewOutcome(event.reviewState);
705-
if (outcome !== null) {
706-
return (
707-
<ReviewVerdictEvent
708-
key={event.id}
709-
event={event}
710-
outcome={outcome}
711-
stale={isPullRequestVerdictStale(event.at, newestCommitAt)}
712-
cwd={detail.workspaceRoot}
713-
onOpen={openOnHost}
714-
reactions={reactions}
715-
/>
716-
);
717-
}
718-
return <LifecycleEvent key={event.id} event={event} term={term} />;
719-
})}
720-
</div>
671+
const rowKey = (row: PullRequestTimelineRow) => {
672+
if (row.kind === "comments") return `comments:${row.events[0]?.id ?? "empty"}`;
673+
const event = row.event;
674+
// A thread's resolution or dismissal remounts it, so its disclosure starts in the new state.
675+
return event.thread === undefined
676+
? event.id
677+
: `thread:${event.id}:${event.thread.isResolved}:${pullRequestReviewOutcome(event.reviewState) === "dismissed"}`;
678+
};
679+
const renderRow = ({ item: row }: { item: PullRequestTimelineRow }) => {
680+
if (row.kind === "comments") {
681+
return (
682+
<ConversationGroup
683+
events={row.events}
684+
editable={editable}
685+
cwd={detail.workspaceRoot}
686+
onOpen={openOnHost}
687+
reactions={reactions}
688+
/>
689+
);
690+
}
691+
const event = row.event;
692+
if (event.thread !== undefined) {
693+
return (
694+
<ReviewThreadEvent
695+
event={event}
696+
thread={event.thread}
697+
editable={editable}
698+
cwd={detail.workspaceRoot}
699+
onOpen={openOnHost}
700+
reactions={reactions}
701+
/>
702+
);
703+
}
704+
if (event.kind === "commit") return <CommitEvent event={event} onOpen={onOpenCommit} />;
705+
const outcome = pullRequestReviewOutcome(event.reviewState);
706+
if (outcome !== null) {
707+
return (
708+
<ReviewVerdictEvent
709+
event={event}
710+
outcome={outcome}
711+
stale={isPullRequestVerdictStale(event.at, newestCommitAt)}
712+
cwd={detail.workspaceRoot}
713+
onOpen={openOnHost}
714+
reactions={reactions}
715+
/>
716+
);
717+
}
718+
return <LifecycleEvent event={event} term={term} />;
719+
};
721720

722-
{events.length === 0 ? (
723-
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
724-
<PullRequestGlyph.pullRequest className="mb-2 size-5" />
725-
<p className="text-xs">No activity yet.</p>
726-
</div>
727-
) : null}
721+
if (events.length === 0) {
722+
return (
723+
<div ref={scrollerRef} className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5">
724+
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
725+
<PullRequestGlyph.pullRequest className="mb-2 size-5" />
726+
<p className="text-xs">No activity yet.</p>
727+
</div>
728728
</div>
729-
</div>
729+
);
730+
}
731+
732+
// Virtualized: a large pull request has thousands of events, and mounting every one stalls
733+
// the page. The connector line is drawn by the content container, behind the rows.
734+
return (
735+
<LegendList<PullRequestTimelineRow>
736+
ref={(list: LegendListRef | null) => scrollerRef(list?.getScrollableNode() ?? null)}
737+
data={rows}
738+
extraData={{ editable, reactions, newestCommitAt }}
739+
keyExtractor={rowKey}
740+
renderItem={renderRow}
741+
estimatedItemSize={140}
742+
className="scrollbar-gutter-both h-full px-4"
743+
contentContainerClassName="relative mx-auto w-full max-w-3xl before:absolute before:bottom-10 before:left-[15px] before:top-6 before:w-px before:bg-border/45"
744+
ListHeaderComponent={<div className="h-5" />}
745+
ListFooterComponent={<div className="h-5" />}
746+
/>
730747
);
731748
}

‎apps/web/src/components/pullRequest/pullRequestDetail.logic.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ export interface PullRequestTimelineThread
483483
readonly replies: ReadonlyArray<PullRequestTimelineEvent>;
484484
}
485485

486-
type PullRequestTimelineRow =
486+
export type PullRequestTimelineRow =
487487
| { readonly kind: "event"; readonly event: PullRequestTimelineEvent }
488488
| { readonly kind: "comments"; readonly events: ReadonlyArray<PullRequestTimelineEvent> };
489489

0 commit comments

Comments
 (0)