mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +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>
87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
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);
|
||
}
|