perf(team): precompute ActivityTimeline session anchors once per render Merge pull request #68 from sardorb3k/perf/activity-timeline-session-precompute

perf(team): precompute ActivityTimeline session anchors once per render
This commit is contained in:
Илия 2026-04-19 09:16:09 +03:00 committed by GitHub
commit b13ab71857
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 152 additions and 14 deletions

View file

@ -75,6 +75,13 @@ const EMPTY_TEAM_NAMES: string[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const DEFAULT_COLLAPSE_MODE = 'default' as const;
function getItemSessionAnchorId(item: TimelineItem): string | undefined {
if (item.type === 'lead-thoughts') {
return item.group.thoughts[0]?.leadSessionId;
}
return undefined;
}
interface ItemCollapseProps {
collapseMode: 'default' | 'managed';
isCollapsed: boolean;
@ -418,13 +425,20 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
setVisibleCount(Infinity);
};
const getItemSessionAnchorId = (item: TimelineItem): string | undefined => {
if (item.type === 'lead-thoughts') {
return item.group.thoughts[0]?.leadSessionId;
// Precompute, per timeline index, the most recent session anchor that appears
// strictly earlier in the list. Replaces an O(n) backward scan during render
// with an O(1) lookup; total work drops from O(n^2) to O(n) per timelineItems
// change.
const previousSessionAnchorByIndex = useMemo<readonly (string | undefined)[]>(() => {
const anchors: (string | undefined)[] = [];
let lastSeen: string | undefined;
for (const item of timelineItems) {
anchors.push(lastSeen);
const anchor = getItemSessionAnchorId(item);
if (anchor) lastSeen = anchor;
}
return undefined;
};
return anchors;
}, [timelineItems]);
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
@ -532,14 +546,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
let sessionSeparator: React.JSX.Element | null = null;
if (realIndex > 0) {
const currSessionId = getItemSessionAnchorId(item);
let prevSessionId: string | undefined;
for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) {
const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]);
if (candidateSessionId) {
prevSessionId = candidateSessionId;
break;
}
}
const prevSessionId = previousSessionAnchorByIndex[realIndex];
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = (
<div

View file

@ -153,4 +153,135 @@ describe('ActivityTimeline session separators', () => {
root.unmount();
});
});
it('renders a separator for every session transition across three lead sessions', async () => {
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'thought-s3',
text: 'thought session 3',
leadSessionId: 'lead-session-3',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-s2',
text: 'thought session 2',
leadSessionId: 'lead-session-2',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-s1',
text: 'thought session 1',
leadSessionId: 'lead-session-1',
from: 'team-lead',
source: 'lead_session',
}),
];
await act(async () => {
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
});
const matches = container.textContent?.match(/New session/g) ?? [];
expect(matches.length).toBe(2);
await act(async () => {
root.unmount();
});
});
it('finds the previous anchor even when many non-anchor items sit between lead thought groups', async () => {
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'thought-newest',
text: 'newest thought',
leadSessionId: 'lead-session-newest',
from: 'team-lead',
source: 'lead_session',
}),
...Array.from({ length: 8 }, (_, i) =>
makeMessage({
messageId: `filler-${i}`,
text: `filler message ${i}`,
leadSessionId: `member-session-${i}`,
from: 'alice',
source: 'inbox',
})
),
makeMessage({
messageId: 'thought-oldest',
text: 'oldest thought',
leadSessionId: 'lead-session-oldest',
from: 'team-lead',
source: 'lead_session',
}),
];
await act(async () => {
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
});
expect(container.textContent).toContain('New session');
await act(async () => {
root.unmount();
});
});
it('does not render a separator when two consecutive lead thoughts share the same session', async () => {
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'thought-a',
text: 'thought a',
leadSessionId: 'lead-session-shared',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-b',
text: 'thought b',
leadSessionId: 'lead-session-shared',
from: 'team-lead',
source: 'lead_session',
}),
];
await act(async () => {
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
});
expect(container.textContent).not.toContain('New session');
await act(async () => {
root.unmount();
});
});
it('handles a single message list without errors or separators', async () => {
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'only',
text: 'only message',
leadSessionId: 'lead-session-1',
from: 'team-lead',
source: 'lead_session',
}),
];
await act(async () => {
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
});
expect(container.textContent).not.toContain('New session');
expect(container.textContent).toContain('only message');
await act(async () => {
root.unmount();
});
});
});