Files
buzz/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx
Max Lampert 040b203f73 perf(desktop): make the Projects surface render-cheap (#6460)
Diagnostic profiling on a large community (101 issues, 258 PRs) showed
Projects tab switches taking 2.5–3.6s, dominated by single React commits
of 0.5–1.5s and per-render recomputation — fetch work was already off
the main thread; the cost was building the UI.

### Measured: tab click → painted, per tab

| tab | before | after |
|---|---|---:|
| projects | 3,608ms | 320–580ms |
| repositories | 3,126–3,534ms | 310–410ms |
| tasks | 395–1,101ms | ~115ms |
| reviews | 322–2,603ms | ~96ms |
| activity | 597–741ms | ~148ms |

Single-commit ceiling dropped from 1,541ms to ≤200ms (growth steps
25–40ms). Fixes in profiled-cost order:

- **Profile popover body mounts only while open.** `UserProfilePopover`
carried seven query subscriptions plus interaction hooks per instance
even when closed; grids mount hundreds (five per card in people stacks,
one per row author) — measured **~40ms per card**, the dominant share of
the 1.2s card-tab commits. The always-mounted shell is now just the
Radix root + trigger; trigger markup, hover timing, and keyboard
handling are unchanged, and hover/tooltip event continuity is preserved
because the trigger never remounts.
- **Incremental row mounting.** The first 12 cards / 30 rows render in
the first commit; the rest stream in 36–60-per-frame low-priority
transitions. Grouped lists trim across group boundaries via a pure,
tested slicer; the mounted count survives in-place refetches.
- **Activity feed**: was rebuilt unmemoized on every render,
markdown-flattening every issue/PR/comment body in the community just to
sort and keep 30 items (~360+ flattens per render on the measured
community). Now memoized, and bodies stay raw until after the sort+slice
— 30 flattens, once per data change.
- **Contribution graph** (always-visible rail, so every tab paid for
it): ~180 day cells each wrapped in a Radix tooltip with per-cell Intl
date formatting per render. Now memoized, cells precomputed once per
data change, native `title` tooltips. (The activity-bar segments keep
their styled Radix tooltips — pinned by an existing spec.)
- **Rows/cards memoized with identity-stable props**: per-row selection
arrays were rebuilt per row per render (O(n²) — 258 PRs × 258-item
arrays each render) and are now hoisted and shared; people arrays derive
inside the memoized cards; the rail's stat walk over every issue/PR is
memoized.
- **`content-visibility: auto`** on cards and rows so offscreen entries
skip layout and paint; **tab switches run in a React transition** so the
click stays responsive while the new tree mounts.

Remaining known cost (out of scope): cold-entry data readiness — the
work-item and activity queries ship thousands of events to compute
counts (2–4s on a large community; see the fan-lifecycle PR). The
structural fix is a relay-side aggregate; tracked as follow-up.

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>
2026-08-22 08:42:55 -06:00

282 lines
8.1 KiB
TypeScript

import {
CheckCircle2,
CircleDot,
FolderGit2,
Folders,
GitMerge,
GitPullRequest,
Hash,
Plus,
} from "lucide-react";
import * as React from "react";
import type {
Project,
ProjectActivitySummary,
ProjectIssue,
ProjectPullRequest,
} from "@/features/projects/hooks";
import {
type ProjectSelectionItem,
projectSelectionPresentation,
} from "@/features/projects/lib/projectSelection";
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
import { useProjectSelection } from "@/features/projects/lib/useProjectSelection";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
import { ProjectsCreateMenu } from "./ProjectsCreateMenu";
import { ProjectsSelectionCountMenu } from "./ProjectsSelectionCountMenu";
import { useCommunities } from "@/features/communities/useCommunities";
import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons";
import {
type OverviewContextStatIcon,
type ProjectsOverviewSection,
projectsOverviewContext,
} from "./projectsOverviewContext";
import { ProjectsOverviewPeople } from "./ProjectsOverviewRail";
export type { ProjectsOverviewSection };
const STAT_ICONS: Record<
OverviewContextStatIcon,
React.ComponentType<{ className?: string }>
> = {
channels: Hash,
completed: CheckCircle2,
merged: GitMerge,
projects: Folders,
repositories: FolderGit2,
reviews: GitPullRequest,
tasks: CircleDot,
};
type ProjectsOverviewPanelProps = {
children: React.ReactNode;
};
type ProjectsOverviewContextPanelProps = {
filter: ProjectsFilter;
issues: ProjectIssue[];
onChatWithAgent: (items: ProjectSelectionItem[]) => void;
onCreateIssue: () => void;
onCreateProject: () => void;
onCreatePullRequest: () => void;
onSelectSection: (section: ProjectsOverviewSection) => void;
profiles?: UserProfileLookup;
projects: Project[];
pullRequests: ProjectPullRequest[];
summaries?: Record<string, ProjectActivitySummary>;
};
function OverviewActionButton({
children,
onClick,
testId,
}: {
children: React.ReactNode;
onClick: () => void;
testId?: string;
}) {
return (
<Button
className="-mx-2 h-7 w-[calc(100%+1rem)] justify-start gap-3 rounded-md px-2 text-left text-sm font-normal hover:bg-muted/70 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
data-testid={testId}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
{children}
</Button>
);
}
function OverviewStatRow({
count,
icon: Icon,
label,
onClick,
}: {
count: number;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}) {
return (
<button
className="-mx-2 flex h-7 w-[calc(100%+1rem)] items-center justify-between gap-3 rounded-md px-2 text-left text-sm hover:bg-muted/70 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
data-testid="projects-overview-stat"
onClick={onClick}
type="button"
>
<span className="flex min-w-0 items-center gap-3 text-muted-foreground">
<Icon className="h-3.5 w-3.5 shrink-0" />
{label}
</span>
<span className="font-medium tabular-nums text-foreground">{count}</span>
</button>
);
}
export function ProjectsOverviewPanel({
children,
}: ProjectsOverviewPanelProps) {
return (
<section data-testid="projects-overview-panel">
<div className="min-w-0">{children}</div>
</section>
);
}
export function ProjectsActivityIntro() {
const { activeCommunity } = useCommunities();
const communityIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl);
const communityIcon = communityIconQuery.data ?? null;
return (
<section
className="pb-8 pt-16 text-center"
data-testid="projects-activity-intro"
>
<div
aria-label={`${activeCommunity?.name ?? "Current"} relay`}
className="mx-auto mb-3 flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl text-4xl"
data-testid="projects-activity-relay-icon"
role="img"
>
{communityIcon ? (
<img
alt=""
className="h-full w-full object-cover"
draggable={false}
src={communityIcon}
/>
) : (
<span aria-hidden="true" className="-translate-y-px leading-none">
🐝
</span>
)}
</div>
<h2
className="text-xl font-semibold tracking-tight text-foreground"
data-testid="projects-page-header"
>
Projects Activity
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
Keeping up with the community has never been easier—or mattered more.
</p>
</section>
);
}
export function ProjectsOverviewContextPanel({
filter,
issues,
onChatWithAgent,
onCreateIssue,
onCreateProject,
onCreatePullRequest,
onSelectSection,
profiles,
projects,
pullRequests,
summaries,
}: ProjectsOverviewContextPanelProps) {
const selection = useProjectSelection();
const selectionItems = selection?.items;
const selectionPresentation = React.useMemo(
() => projectSelectionPresentation(selectionItems ?? []),
[selectionItems],
);
// Memoized: the rail re-renders with every Projects-view state change, and
// the context stats walk every issue and pull request in the community.
const context = React.useMemo(
() =>
projectsOverviewContext({
filter,
issues,
projects,
pullRequests,
summaries,
}),
[filter, issues, projects, pullRequests, summaries],
);
const actionHandler =
context.action?.kind === "issue"
? onCreateIssue
: context.action?.kind === "pullRequest"
? onCreatePullRequest
: onCreateProject;
return (
<div
className="min-w-0 overflow-hidden rounded-2xl bg-background"
data-testid="projects-overview-context-panel"
>
<div className="px-4 pb-3 pt-3">
{selectionPresentation && selection ? (
<ProjectsSelectionCountMenu
onChatWithAgent={onChatWithAgent}
onCreatePullRequest={onCreatePullRequest}
presentation={selectionPresentation}
selectionItems={selection.items}
/>
) : (
<div className="flex min-w-0 items-center justify-between gap-2">
<h2
className="min-w-0 truncate text-sm font-normal text-muted-foreground/70"
data-testid="projects-overview-context-title"
>
{context.title}
</h2>
<ProjectsCreateMenu
compact
onCreateIssue={onCreateIssue}
onCreateProject={onCreateProject}
onCreatePullRequest={onCreatePullRequest}
/>
</div>
)}
{selectionPresentation ? null : (
<>
<div className="space-y-0.5 pt-2">
{context.action ? (
<OverviewActionButton
onClick={actionHandler}
testId={context.action.testId}
>
<Plus className="h-3.5 w-3.5" />
{context.action.label}
</OverviewActionButton>
) : null}
<section
className="space-y-0.5 text-sm"
data-testid="projects-overview-stats-pod"
>
{context.stats.map((stat) => (
<OverviewStatRow
count={stat.count}
icon={STAT_ICONS[stat.icon]}
key={`${stat.section}:${stat.label}`}
label={stat.label}
onClick={() => onSelectSection(stat.section)}
/>
))}
</section>
</div>
{context.people.length > 0 ? (
<div className="mt-3 space-y-3 py-3">
<ProjectsOverviewPeople
people={context.people}
profiles={profiles}
/>
</div>
) : null}
</>
)}
</div>
</div>
);
}