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 DEFAULT_COLLAPSE_MODE = 'default' as const;
|
||||||
const VIRTUALIZER_OVERSCAN = 8;
|
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
|
* Per-kind height estimates for `estimateSize`. These are rough initial guesses
|
||||||
* only; the virtualizer re-measures rows as they mount via `measureElement`
|
* only; the virtualizer re-measures rows as they mount via `measureElement`
|
||||||
|
|
@ -587,14 +596,15 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
return rows;
|
return rows;
|
||||||
}, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]);
|
}, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]);
|
||||||
|
|
||||||
// Virtualizer gate — dormant unless the parent explicitly opts in via
|
// Virtualizer gate — activates only when the parent opts in via
|
||||||
// `viewport.virtualizationEnabled`. The contract carries this flag so the
|
// `viewport.virtualizationEnabled`, the scroll element ref is present, and
|
||||||
// (large) virtualized render path can land before any caller flips the
|
// the row count is large enough for virtualization to pay for itself. Below
|
||||||
// switch, and can be toggled on per-layout once measurement is validated.
|
// the threshold the direct render path is both simpler and faster, so we
|
||||||
|
// keep it for short lists.
|
||||||
const shouldVirtualize =
|
const shouldVirtualize =
|
||||||
viewport?.virtualizationEnabled === true &&
|
viewport?.virtualizationEnabled === true &&
|
||||||
viewport.scrollElementRef != null &&
|
viewport.scrollElementRef != null &&
|
||||||
renderRows.length > 0;
|
renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD;
|
||||||
|
|
||||||
// DOM-measured distance from the scroll container's scroll origin to the
|
// DOM-measured distance from the scroll container's scroll origin to the
|
||||||
// timeline root. Hand-summing composer/status/padding heights would drift as
|
// timeline root. Hand-summing composer/status/padding heights would drift as
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
scrollElementRef: activeScrollContainerRef,
|
scrollElementRef: activeScrollContainerRef,
|
||||||
observerRoot: activeScrollContainerRef,
|
observerRoot: activeScrollContainerRef,
|
||||||
scrollMargin: 0,
|
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]);
|
}, [activeScrollContainerRef]);
|
||||||
const handleExpandContent = useCallback(() => {
|
const handleExpandContent = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -464,3 +464,129 @@ describe('ActivityTimeline viewport observerRoot', () => {
|
||||||
scrollHost.remove();
|
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