Files
buzz/desktop/src/shared/hooks/useIncrementalMount.ts
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

87 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as React from "react";
/**
* Mounts a long list progressively: the first `initial` items render in the
* first commit (fast click→paint), then the count grows by `step` in chained
* low-priority transitions until everything is mounted. This bounds the cost
* of any single React commit — a one-shot mount of hundreds of heavy rows
* blocked the main thread for 0.5–1.5s on the Projects tabs.
*
* The count persists across in-place data updates (a refetch re-rendering an
* already-grown list stays fully mounted) and resets naturally when the
* consuming component remounts (tab switches swap subtrees).
*
* Two guards keep the single-commit bound honest without a remount:
* - `enabled: false` (the inactive layout of a list/grid pair) holds the
* count at `initial`, so switching layouts mounts the other collection
* incrementally instead of all at once from its already-grown counter.
* - A `total` shrink (scope narrowed) snaps the count down — everything
* visible stays mounted — so restoring the wider scope re-grows stepwise
* instead of bulk-mounting the whole collection in one commit.
*/
export function useIncrementalMount(
total: number,
initial = 30,
step = 60,
enabled = true,
): number {
const [count, setCount] = React.useState(initial);
React.useEffect(() => {
if (!enabled) {
if (count !== initial) setCount(initial);
return;
}
if (total < count) {
setCount(Math.max(initial, total));
return;
}
if (count >= total) return;
let cancelled = false;
// rAF so a frame paints between growth steps; the transition keeps each
// growth commit interruptible by user input.
const frame = window.requestAnimationFrame(() => {
if (cancelled) return;
React.startTransition(() => {
setCount((current) => Math.min(current + step, total));
});
});
return () => {
cancelled = true;
window.cancelAnimationFrame(frame);
};
}, [count, enabled, initial, step, total]);
return Math.min(count, total);
}
/**
* Trims grouped rows to a total row budget, preserving group order and
* dropping empty groups. Pure core for incrementally-mounted grouped lists.
*/
export function sliceGroupedRows<G extends { rows: readonly unknown[] }>(
groups: readonly G[],
limit: number,
): G[] {
const result: G[] = [];
let remaining = limit;
for (const group of groups) {
if (remaining <= 0) break;
if (group.rows.length <= remaining) {
result.push(group);
remaining -= group.rows.length;
} else {
result.push({ ...group, rows: group.rows.slice(0, remaining) });
remaining = 0;
}
}
return result;
}
/** Total row count across groups. */
export function countGroupedRows(
groups: readonly { rows: readonly unknown[] }[],
): number {
return groups.reduce((sum, group) => sum + group.rows.length, 0);
}