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:
parent
b9c2dd5480
commit
05f68ced44
3 changed files with 145 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue