Repository navigation
perf(web): the pull request timeline mounts only the events near the viewport #27795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { | ||
| EnvironmentId, | ||
| ProjectId, | ||
| SourceControlProviderKind, | ||
| type PullRequestDetailView, | ||
| } from "@t3tools/contracts"; | ||
| import { expect, it } from "vite-plus/test"; | ||
|
|
||
| import { render } from "../../../test/render"; | ||
| import "../../index.css"; | ||
| import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; | ||
|
|
||
| const COMMITS = 2000; | ||
|
|
||
| const detail: PullRequestDetailView = { | ||
| provider: SourceControlProviderKind.make("github"), | ||
| projectId: ProjectId.make("project"), | ||
| projectTitle: "Project", | ||
| workspaceRoot: "/workspace", | ||
| repository: "owner/repo", | ||
| number: 1, | ||
| title: "Huge pull request", | ||
| body: "", | ||
| url: "https://github.com/owner/repo/pull/1", | ||
| author: { login: "author", name: null, avatarUrl: null }, | ||
| viewer: "author", | ||
| state: "open", | ||
| isDraft: false, | ||
| mergeability: "mergeable", | ||
| additions: 1, | ||
| deletions: 0, | ||
| changedFiles: 1, | ||
| headBranch: "feature", | ||
| baseBranch: "main", | ||
| createdAt: "2026-09-01T00:00:00Z", | ||
| updatedAt: "2026-09-01T00:00:00Z", | ||
| mergedAt: null, | ||
| closedAt: null, | ||
| reviewers: [], | ||
| labels: [], | ||
| checks: [], | ||
| comments: [], | ||
| commentCount: 0, | ||
| commentsTruncated: false, | ||
| reviewThreads: [], | ||
| commits: Array.from({ length: COMMITS }, (_, index) => ({ | ||
| oid: `${index.toString(16).padStart(7, "0")}${"0".repeat(33)}`, | ||
| messageHeadline: `Commit number ${index}`, | ||
| committedDate: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(), | ||
| })), | ||
| mergeCapabilities: { merge: false, squash: false, rebase: false }, | ||
| capabilities: { | ||
| diff: true, | ||
| comment: true, | ||
| search: true, | ||
| actions: [], | ||
| mergeMethods: [], | ||
| review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, | ||
| reviewers: { request: false, listCandidates: false }, | ||
| edit: { changeRequest: false, comment: false }, | ||
| }, | ||
| viewerPermissions: { | ||
| actions: [], | ||
| comment: false, | ||
| resolve: false, | ||
| verdicts: [], | ||
| requestReviewers: false, | ||
| }, | ||
| }; | ||
|
|
||
| it("mounts only the timeline rows near the viewport of a huge pull request", async () => { | ||
| await using screen = await render( | ||
| <div style={{ height: 600 }}> | ||
| <PullRequestTimelineTab | ||
| scrollerRef={() => {}} | ||
| detail={detail} | ||
| environmentId={EnvironmentId.make("environment")} | ||
| reference={detail} | ||
| order="oldest" | ||
| onOpenCommit={() => {}} | ||
| onRefresh={() => {}} | ||
| /> | ||
| </div>, | ||
| ); | ||
| await expect.element(screen.getByText("Commit number 0")).toBeVisible(); | ||
| const mounted = screen.container.querySelectorAll("button").length; | ||
| expect(mounted).toBeGreaterThan(0); | ||
| expect(mounted).toBeLessThan(COMMITS / 10); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| import { | ||
| buildPullRequestTimeline, | ||
| groupPullRequestTimelineConversations, | ||
| type PullRequestTimelineRow, | ||
| isPullRequestVerdictStale, | ||
| newestPullRequestCommitAt, | ||
| pullRequestReviewOutcome, | ||
|
|
@@ -56,6 +57,7 @@ import { | |
| pullRequestReviewOutcomeToneClassName, | ||
| } from "./pullRequestPresentation"; | ||
| import { PullRequestGlyph } from "./pullRequestIcons"; | ||
| import { LegendList, type LegendListRef } from "@legendapp/list/react"; | ||
|
|
||
| interface ReactionSurface { | ||
| readonly canReact: boolean; | ||
|
|
@@ -634,7 +636,7 @@ export function PullRequestTimelineTab({ | |
| onOpenCommit, | ||
| onRefresh, | ||
| }: { | ||
| scrollerRef: (node: HTMLDivElement | null) => void; | ||
| scrollerRef: (node: HTMLElement | null) => void; | ||
| detail: PullRequestDetailView; | ||
| environmentId: EnvironmentId; | ||
| threadRef?: ScopedThreadRef | null; | ||
|
|
@@ -666,66 +668,81 @@ export function PullRequestTimelineTab({ | |
| void readLocalApi()?.shell.openExternal(url); | ||
| }; | ||
|
|
||
| return ( | ||
| <div ref={scrollerRef} className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5"> | ||
| <div className="mx-auto max-w-3xl"> | ||
| <div className="relative"> | ||
| <span aria-hidden className="absolute bottom-5 left-[15px] top-1 w-px bg-border/45" /> | ||
| {rows.map((row) => { | ||
| if (row.kind === "comments") { | ||
| return ( | ||
| <ConversationGroup | ||
| key={`comments:${row.events[0]?.id ?? "empty"}`} | ||
| events={row.events} | ||
| editable={editable} | ||
| cwd={detail.workspaceRoot} | ||
| onOpen={openOnHost} | ||
| reactions={reactions} | ||
| /> | ||
| ); | ||
| } | ||
| const event = row.event; | ||
| if (event.thread !== undefined) { | ||
| return ( | ||
| <ReviewThreadEvent | ||
| key={`${reactions.reference.projectId}#${reactions.reference.number}:thread:${event.id}:${event.thread.isResolved}:${pullRequestReviewOutcome(event.reviewState) === "dismissed"}`} | ||
| event={event} | ||
| thread={event.thread} | ||
| editable={editable} | ||
| cwd={detail.workspaceRoot} | ||
| onOpen={openOnHost} | ||
| reactions={reactions} | ||
| /> | ||
| ); | ||
| } | ||
| if (event.kind === "commit") { | ||
| return <CommitEvent key={event.id} event={event} onOpen={onOpenCommit} />; | ||
| } | ||
| const outcome = pullRequestReviewOutcome(event.reviewState); | ||
| if (outcome !== null) { | ||
| return ( | ||
| <ReviewVerdictEvent | ||
| key={event.id} | ||
| event={event} | ||
| outcome={outcome} | ||
| stale={isPullRequestVerdictStale(event.at, newestCommitAt)} | ||
| cwd={detail.workspaceRoot} | ||
| onOpen={openOnHost} | ||
| reactions={reactions} | ||
| /> | ||
| ); | ||
| } | ||
| return <LifecycleEvent key={event.id} event={event} term={term} />; | ||
| })} | ||
| </div> | ||
| const rowKey = (row: PullRequestTimelineRow) => { | ||
| if (row.kind === "comments") return `comments:${row.events[0]?.id ?? "empty"}`; | ||
| const event = row.event; | ||
| // A thread's resolution or dismissal remounts it, so its disclosure starts in the new state. | ||
| return event.thread === undefined | ||
| ? event.id | ||
| : `thread:${event.id}:${event.thread.isResolved}:${pullRequestReviewOutcome(event.reviewState) === "dismissed"}`; | ||
| }; | ||
| const renderRow = ({ item: row }: { item: PullRequestTimelineRow }) => { | ||
| if (row.kind === "comments") { | ||
| return ( | ||
| <ConversationGroup | ||
| events={row.events} | ||
| editable={editable} | ||
| cwd={detail.workspaceRoot} | ||
| onOpen={openOnHost} | ||
| reactions={reactions} | ||
| /> | ||
| ); | ||
| } | ||
| const event = row.event; | ||
| if (event.thread !== undefined) { | ||
| return ( | ||
| <ReviewThreadEvent | ||
| event={event} | ||
| thread={event.thread} | ||
| editable={editable} | ||
| cwd={detail.workspaceRoot} | ||
| onOpen={openOnHost} | ||
| reactions={reactions} | ||
| /> | ||
| ); | ||
| } | ||
| if (event.kind === "commit") return <CommitEvent event={event} onOpen={onOpenCommit} />; | ||
| const outcome = pullRequestReviewOutcome(event.reviewState); | ||
| if (outcome !== null) { | ||
| return ( | ||
| <ReviewVerdictEvent | ||
| event={event} | ||
| outcome={outcome} | ||
| stale={isPullRequestVerdictStale(event.at, newestCommitAt)} | ||
| cwd={detail.workspaceRoot} | ||
| onOpen={openOnHost} | ||
| reactions={reactions} | ||
| /> | ||
| ); | ||
| } | ||
| return <LifecycleEvent event={event} term={term} />; | ||
| }; | ||
|
|
||
| {events.length === 0 ? ( | ||
| <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground"> | ||
| <PullRequestGlyph.pullRequest className="mb-2 size-5" /> | ||
| <p className="text-xs">No activity yet.</p> | ||
| </div> | ||
| ) : null} | ||
| if (events.length === 0) { | ||
| return ( | ||
| <div ref={scrollerRef} className="scrollbar-gutter-both h-full overflow-y-auto px-4 py-5"> | ||
| <div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground"> | ||
| <PullRequestGlyph.pullRequest className="mb-2 size-5" /> | ||
| <p className="text-xs">No activity yet.</p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // Virtualized: a large pull request has thousands of events, and mounting every one stalls | ||
| // the page. The connector line is drawn by the content container, behind the rows. | ||
| return ( | ||
| <LegendList<PullRequestTimelineRow> | ||
| ref={(list: LegendListRef | null) => scrollerRef(list?.getScrollableNode() ?? null)} | ||
| data={rows} | ||
| extraData={{ editable, reactions, newestCommitAt }} | ||
| keyExtractor={rowKey} | ||
| renderItem={renderRow} | ||
| estimatedItemSize={140} | ||
| className="scrollbar-gutter-both h-full px-4" | ||
| 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" | ||
| ListHeaderComponent={<div className="h-5" />} | ||
| ListFooterComponent={<div className="h-5" />} | ||
| /> | ||
|
Comment on lines
+721
to
+746
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
pullRequest/PullRequestTimelineTab.tsx:735Scrolling an edited comment out of the virtualized window unmounts its
ConversationCard, closing the editor and discarding the unsaved draft.editinganddraftlive only in row-local state, so preserve drafts outside virtualized rows or keep editing rows mounted.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: