perf(team): enable virtualization past threshold + tests

Final step of the virtualization plan. Turns the virtualized render
path on in production behind a row-count threshold, and adds regression
tests covering every gate.

- `VIRTUALIZATION_ROW_THRESHOLD = 60`. Short lists stay on the direct
  render path (no wrapper, no position: absolute, no measurement
  churn). Above the threshold the virtualizer takes over. Threshold is
  sized so conversations under ~one session of activity don't pay the
  virtualization cost; it activates once scrolling through a longer
  history.
- `shouldVirtualize` now requires `renderRows.length >= threshold` in
  addition to the existing opt-in and scroll-ref checks.
- `MessagesPanel` opts into virtualization for every layout it wires
  (inline / sidebar / bottom-sheet). The internal threshold then
  decides when to actually enable it, so callers don't need per-layout
  heuristics.
- Tests: adds a new `ActivityTimeline virtualization threshold` block
  covering (a) below-threshold list stays on the direct path,
  (b) no viewport → direct path regardless of count, (c) above
  threshold + viewport with `virtualizationEnabled` flips to the
  virtualized render path (simulated by clicking "show all" past
  pagination).

With this in, #70#74 combine to deliver:
- correct IntersectionObserver roots in scroll containers
- atomic render rows with stable keys
- windowed rendering with DOM-measured scrollMargin and measureElement
- auto-on when the cost of direct rendering actually shows up
This commit is contained in:
Mike 2026-04-20 00:56:28 +05:00
parent b9c2dd5480
commit 05f68ced44
3 changed files with 145 additions and 6 deletions

View file

@ -136,6 +136,15 @@ const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const DEFAULT_COLLAPSE_MODE = 'default' as const;
const VIRTUALIZER_OVERSCAN = 8;
/**
* Row count above which virtualization is worth its complexity cost. Below
* this, the direct render path is both simpler and faster (no wrapper div,
* no position: absolute, no measurement churn). Chosen so conversations under
* roughly one session of activity stay on the direct path and the virtualized
* path only activates when scrolling behavior actually starts to matter.
*/
const VIRTUALIZATION_ROW_THRESHOLD = 60;
/**
* Per-kind height estimates for `estimateSize`. These are rough initial guesses
* only; the virtualizer re-measures rows as they mount via `measureElement`
@ -587,14 +596,15 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
return rows;
}, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]);
// Virtualizer gate — dormant unless the parent explicitly opts in via
// `viewport.virtualizationEnabled`. The contract carries this flag so the
// (large) virtualized render path can land before any caller flips the
// switch, and can be toggled on per-layout once measurement is validated.
// Virtualizer gate — activates only when the parent opts in via
// `viewport.virtualizationEnabled`, the scroll element ref is present, and
// the row count is large enough for virtualization to pay for itself. Below
// the threshold the direct render path is both simpler and faster, so we
// keep it for short lists.
const shouldVirtualize =
viewport?.virtualizationEnabled === true &&
viewport.scrollElementRef != null &&
renderRows.length > 0;
renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD;
// DOM-measured distance from the scroll container's scroll origin to the
// timeline root. Hand-summing composer/status/padding heights would drift as

View file

@ -255,7 +255,10 @@ export const MessagesPanel = memo(function MessagesPanel({
scrollElementRef: activeScrollContainerRef,
observerRoot: activeScrollContainerRef,
scrollMargin: 0,
virtualizationEnabled: false,
// Opt into virtualization; ActivityTimeline keeps the direct render
// path for short lists and only switches to the windowed path once
// the row count crosses its internal threshold.
virtualizationEnabled: true,
};
}, [activeScrollContainerRef]);
const handleExpandContent = useCallback(() => {

View file

@ -464,3 +464,129 @@ describe('ActivityTimeline viewport observerRoot', () => {
scrollHost.remove();
});
});
describe('ActivityTimeline virtualization threshold', () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
const buildMany = (count: number): InboxMessage[] =>
Array.from({ length: count }, (_, i) =>
makeMessage({
messageId: `msg-${i}`,
text: `message ${i}`,
from: 'alice',
source: 'inbox',
leadSessionId: `member-session-${i}`,
})
);
it('does not enter the virtualized render path when the row count is below the threshold', async () => {
const scrollHost = document.createElement('div');
document.body.appendChild(scrollHost);
const scrollRef = { current: scrollHost };
const root = createRoot(container);
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages: buildMany(10),
teamName: 'demo-team',
viewport: {
scrollElementRef: scrollRef,
observerRoot: scrollRef,
scrollMargin: 0,
virtualizationEnabled: true,
},
})
);
});
// Virtualized path wraps items in an absolute-position container; the
// direct path does not. Assert the wrapper is absent.
const absoluteWrapper = container.querySelector<HTMLDivElement>('div[style*="position: relative"]');
expect(absoluteWrapper).toBeNull();
// Sanity check: direct render still emits at least one activity item.
expect(container.textContent).toContain('message 0');
await act(async () => {
root.unmount();
});
scrollHost.remove();
});
it('falls back to the direct render path when no viewport is provided', async () => {
const root = createRoot(container);
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages: buildMany(80),
teamName: 'demo-team',
})
);
});
const absoluteWrapper = container.querySelector<HTMLDivElement>('div[style*="position: relative"]');
expect(absoluteWrapper).toBeNull();
expect(container.textContent).toContain('message 0');
await act(async () => {
root.unmount();
});
});
it('enters the virtualized render path when row count crosses the threshold', async () => {
const scrollHost = document.createElement('div');
document.body.appendChild(scrollHost);
const scrollRef = { current: scrollHost };
const root = createRoot(container);
await act(async () => {
root.render(
React.createElement(ActivityTimeline, {
messages: buildMany(80),
teamName: 'demo-team',
viewport: {
scrollElementRef: scrollRef,
observerRoot: scrollRef,
scrollMargin: 0,
virtualizationEnabled: true,
},
})
);
});
// Default pagination caps visible rows at 30, which stays below the
// threshold, so the direct render path is in effect here. Click "show
// all" to expose every message — that pushes row count past the gate.
const showAllButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent?.toLowerCase().includes('show all')
);
expect(showAllButton).toBeDefined();
await act(async () => {
showAllButton?.click();
});
// Virtualized path: sized container div with `position: relative`
// directly inside the timeline root. jsdom serialises style attributes
// with spaces after the colon, so match case-insensitively.
const html = container.innerHTML;
expect(html.toLowerCase()).toMatch(/position:\s*relative/);
await act(async () => {
root.unmount();
});
scrollHost.remove();
});
});