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:
commit
b13ab71857
2 changed files with 152 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue