mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
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>
282 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|