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

158 lines
5.5 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import { countGroupedRows, sliceGroupedRows } from "./useIncrementalMount.ts";
function group(id, rowCount) {
return { id, rows: Array.from({ length: rowCount }, (_, i) => `${id}-${i}`) };
}
test("sliceGroupedRows trims across group boundaries in order", () => {
const groups = [group("a", 3), group("b", 4), group("c", 2)];
assert.deepEqual(
sliceGroupedRows(groups, 5).map((g) => [g.id, g.rows.length]),
[
["a", 3],
["b", 2],
],
);
});
test("sliceGroupedRows keeps whole groups untouched when under budget", () => {
const groups = [group("a", 3), group("b", 4)];
const sliced = sliceGroupedRows(groups, 10);
assert.equal(sliced.length, 2);
// Fully-included groups keep their identity (no needless clones).
assert.equal(sliced[0], groups[0]);
assert.equal(sliced[1], groups[1]);
});
test("sliceGroupedRows with zero budget is empty; counts add up", () => {
const groups = [group("a", 3), group("b", 4)];
assert.deepEqual(sliceGroupedRows(groups, 0), []);
assert.equal(countGroupedRows(groups), 7);
});
// --- Hook behavior: growth, layout gating, shrink reset -------------------
async function withHookHarness(run) {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const originals = {
window: globalThis.window,
document: globalThis.document,
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
act: globalThis.IS_REACT_ACT_ENVIRONMENT,
};
const frames = [];
dom.window.requestAnimationFrame = (cb) => frames.push(cb) && frames.length;
dom.window.cancelAnimationFrame = (id) => {
frames[id - 1] = null;
};
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
try {
const React = (await import("react")).default;
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { useIncrementalMount } = await import("./useIncrementalMount.ts");
const seen = [];
function Probe({ total, enabled }) {
seen.push(useIncrementalMount(total, 2, 3, enabled));
return null;
}
const root = createRoot(
dom.window.document.body.appendChild(
dom.window.document.createElement("div"),
),
);
const render = async (total, enabled = true) => {
await act(async () => {
root.render(React.createElement(Probe, { enabled, total }));
});
};
const flushFrames = async () => {
// Growth chains one rAF per step; run until quiescent.
for (let i = 0; i < 20 && frames.some(Boolean); i += 1) {
const pending = frames.splice(0, frames.length).filter(Boolean);
await act(async () => {
for (const cb of pending) cb();
});
}
};
await run({ render, flushFrames, seen, root, act });
await act(async () => root.unmount());
} finally {
globalThis.window = originals.window;
globalThis.document = originals.document;
if (originals.navigator) {
Object.defineProperty(globalThis, "navigator", originals.navigator);
}
globalThis.IS_REACT_ACT_ENVIRONMENT = originals.act;
}
}
test("hook grows stepwise to total and persists across data updates", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
await render(8);
assert.equal(seen.at(-1), 2); // initial window paints first
await flushFrames();
assert.equal(seen.at(-1), 8); // grown 2→5→8
await render(8); // in-place update: stays fully mounted
assert.equal(seen.at(-1), 8);
});
});
test("hook holds at initial while disabled and grows on activation", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
await render(9, false);
await flushFrames();
assert.equal(seen.at(-1), 2, "disabled layout must not grow");
await render(9, true);
await flushFrames();
assert.equal(seen.at(-1), 9);
// Deactivating resets to the initial window for the next activation.
await render(9, false);
await flushFrames();
assert.equal(seen.at(-1), 2);
});
});
test("hook snaps down on total shrink and re-grows stepwise on restore", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
await render(9);
await flushFrames();
assert.equal(seen.at(-1), 9);
await render(4); // scope narrowed
await flushFrames();
assert.equal(seen.at(-1), 4);
await render(9); // scope restored: no one-shot bulk mount
assert.equal(seen.at(-1), 4);
await flushFrames();
assert.equal(seen.at(-1), 9);
});
});
test("list dwell then grid switch: first enabled commit stays at the initial window", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
// Dwell in list layout (counter disabled) long enough that an ungated
// counter would have grown to the full collection.
await render(20, false);
await flushFrames();
assert.equal(seen.at(-1), 2, "dormant counter must hold at initial");
// Switch to grid: the FIRST grid commit must mount only the initial
// window — never the whole collection in one commit.
await render(20, true);
assert.equal(seen.at(-1), 2, "first grid commit stays at initial");
await flushFrames();
assert.equal(seen.at(-1), 20, "then grows stepwise to the total");
});
});