Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
64f531b
perf: memoize VList timeline items to prevent mass re-renders
Just-Insane Apr 10, 2026
c9f4537
fix(timeline): make mutationVersion optional in UseProcessedTimelineO…
Just-Insane Apr 10, 2026
885cf0b
fix(timeline): satisfy lint rules in TimelineItem memo component
Just-Insane Apr 10, 2026
70e4552
chore: add changeset for perf-timeline-item-memo
Just-Insane Apr 10, 2026
2b9758f
fix(timeline): preempt atBottom to prevent Jump to Latest flashing at…
Just-Insane Apr 10, 2026
8aea0a1
fix(timeline): suppress intermediate VList scroll events after progra…
Just-Insane Apr 10, 2026
7322feb
fix(timeline): set programmatic guard in scrollToBottom; keep guard a…
Just-Insane Apr 10, 2026
bc4eb26
fix(notifications): defer notification jump until live timeline is no…
Just-Insane Apr 11, 2026
c3d7cf5
fix(timeline): skip scroll-cache save when viewing a historical event…
Just-Insane Apr 11, 2026
76c8f70
test(timeline): add useProcessedTimeline unit tests + fix stale itemI…
Just-Insane Apr 11, 2026
32c504c
perf(timeline): restore VList cache + skip 80 ms timer on room revisit
Just-Insane Apr 9, 2026
57ce5a0
fix(timeline): correct scroll-cache save/load paths
Just-Insane Apr 9, 2026
2ee4554
fix(timeline): save scroll cache in pendingReadyRef recovery path
Just-Insane Apr 10, 2026
bdea2e7
chore: add changeset for perf-timeline-scroll-cache
Just-Insane Apr 10, 2026
89f4632
fix(timeline): preempt atBottom to prevent Jump to Latest flashing at…
Just-Insane Apr 10, 2026
394aca2
fix(timeline): suppress intermediate VList scroll events after progra…
Just-Insane Apr 10, 2026
44c9dd2
test(timeline): add roomScrollCache unit tests
Just-Insane Apr 11, 2026
ac4fd94
fix: auto-format roomScrollCache test file for prettier compliance
Just-Insane Apr 12, 2026
781a8e2
refactor(timeline): replace eslint-disables with custom areEqual comp…
Just-Insane Apr 14, 2026
4e5b037
fix(timeline): address review feedback for timeline-item-memo
Just-Insane Apr 15, 2026
f78496d
fix: scope roomScrollCache to userId
Just-Insane Apr 15, 2026
5af258a
fix(timeline): use timestamp settling window for programmatic scroll …
Just-Insane Apr 15, 2026
8e07aec
fix(timeline): arm scroll guard in ResizeObserver and scrollToBottom
Just-Insane Apr 15, 2026
93b7fb8
fix(timeline): remove scroll settling guard, match upstream scroll ap…
Just-Insane Apr 16, 2026
6dcb99e
fix(timeline): add hysteresis to atBottom threshold
Just-Insane Apr 16, 2026
2727ac7
fix(timeline): suppress jump button flash during initial scroll settle
Just-Insane Apr 16, 2026
be8c28a
fix(timeline): remove scroll hysteresis, match upstream 100px threshold
Just-Insane Apr 16, 2026
7bddc4f
fix(timeline): chase bottom when content grows while user is pinned
Just-Insane Apr 16, 2026
29fa6ea
fix(timeline): restore useLayoutEffect auto-scroll, fix new-message s…
Just-Insane Apr 16, 2026
4e4cace
fix(timeline): restore upstream scroll pattern for new messages
Just-Insane Apr 16, 2026
1ac909d
fix(timeline): align scrollToBottom with upstream, fix eventId race
Just-Insane Apr 17, 2026
c8189ea
fix(timeline): skip scroll handler during init to prevent jitter
Just-Insane Apr 17, 2026
5c94190
perf(timeline): skip redundant re-render on TimelineReset
Just-Insane Apr 17, 2026
29fc1d0
perf(timeline): hide content during genuine TimelineReset re-render
Just-Insane Apr 17, 2026
4f2e3e0
feat(timeline): show skeleton overlay while measuring first-visit rooms
Just-Insane Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perf-timeline-item-memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Memoize individual VList timeline items to prevent mass re-renders when unrelated state changes (e.g. typing indicators, read receipts, or new messages while not at the bottom).
5 changes: 5 additions & 0 deletions .changeset/perf-timeline-scroll-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Cache VList item heights across room visits to restore scroll position instantly and skip the 80 ms opacity-fade stabilisation timer on revisit.
295 changes: 261 additions & 34 deletions src/app/features/room/RoomTimeline.tsx

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions src/app/hooks/timeline/useProcessedTimeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import type { EventTimeline, EventTimelineSet, MatrixEvent } from '$types/matrix-sdk';
import { useProcessedTimeline } from './useProcessedTimeline';

// ---------------------------------------------------------------------------
// Minimal fakes
// ---------------------------------------------------------------------------

function makeEvent(
id: string,
opts: {
sender?: string;
type?: string;
ts?: number;
content?: Record<string, unknown>;
} = {}
): MatrixEvent {
const {
sender = '@alice:test',
type = 'm.room.message',
ts = 1_000,
content = { body: 'hello' },
} = opts;
return {
getId: () => id,
getSender: () => sender,
isRedacted: () => false,
getTs: () => ts,
getType: () => type,
threadRootId: undefined,
getContent: () => content,
getRelation: () => null,
isRedaction: () => false,
} as unknown as MatrixEvent;
}

const fakeTimelineSet = {} as EventTimelineSet;

function makeTimeline(events: MatrixEvent[]): EventTimeline {
return {
getEvents: () => events,
getTimelineSet: () => fakeTimelineSet,
} as unknown as EventTimeline;
}

/** Default options — keeps tests concise; individual tests override what they need. */
const defaults = {
ignoredUsersSet: new Set<string>(),
showHiddenEvents: false,
showTombstoneEvents: false,
mxUserId: '@alice:test',
readUptoEventId: undefined,
hideMembershipEvents: false,
hideNickAvatarEvents: false,
isReadOnly: false,
hideMemberInReadOnly: false,
} as const;

// ---------------------------------------------------------------------------
// Helpers to derive `items` from a linked-timeline list
// index 0 = first event in first timeline, etc.
// ---------------------------------------------------------------------------
function makeItems(count: number, startIndex = 0): number[] {
return Array.from({ length: count }, (_, i) => startIndex + i);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('useProcessedTimeline', () => {
it('returns an empty array when there are no events', () => {
const { result } = renderHook(() =>
useProcessedTimeline({
...defaults,
items: [],
linkedTimelines: [makeTimeline([])],
})
);
expect(result.current).toHaveLength(0);
});

it('returns one ProcessedEvent per visible event', () => {
const events = [makeEvent('$e1'), makeEvent('$e2'), makeEvent('$e3')];
const timeline = makeTimeline(events);

const { result } = renderHook(() =>
useProcessedTimeline({
...defaults,
items: makeItems(3),
linkedTimelines: [timeline],
})
);

expect(result.current).toHaveLength(3);
expect(result.current[0].id).toBe('$e1');
expect(result.current[2].id).toBe('$e3');
});

it('collapses consecutive messages from the same sender within 2 minutes', () => {
const events = [
makeEvent('$e1', { ts: 1_000 }),
makeEvent('$e2', { ts: 60_000 }), // same sender, ~1 min later
];
const timeline = makeTimeline(events);

const { result } = renderHook(() =>
useProcessedTimeline({
...defaults,
items: makeItems(2),
linkedTimelines: [timeline],
})
);

expect(result.current[1].collapsed).toBe(true);
});

it('does NOT collapse messages from the same sender more than 2 minutes apart', () => {
const events = [
makeEvent('$e1', { ts: 1_000 }),
makeEvent('$e2', { ts: 3 * 60_000 }), // 3 min later
];
const timeline = makeTimeline(events);

const { result } = renderHook(() =>
useProcessedTimeline({
...defaults,
items: makeItems(2),
linkedTimelines: [timeline],
})
);

expect(result.current[1].collapsed).toBe(false);
});

// -------------------------------------------------------------------------
// Stable-ref optimisation
// -------------------------------------------------------------------------

it('reuses the same ProcessedEvent reference when nothing changed (stable-ref)', () => {
const events = [makeEvent('$e1'), makeEvent('$e2')];
const timeline = makeTimeline(events);
const items = makeItems(2);

const { result, rerender } = renderHook(
({ ver }) =>
useProcessedTimeline({
...defaults,
items,
linkedTimelines: [timeline],
mutationVersion: ver,
}),
{ initialProps: { ver: 0 } }
);

const firstRender = result.current;

// Re-render with the same mutationVersion — refs should be reused
rerender({ ver: 0 });

expect(result.current[0]).toBe(firstRender[0]);
expect(result.current[1]).toBe(firstRender[1]);
});

it('creates fresh ProcessedEvent objects when mutationVersion increments', () => {
const events = [makeEvent('$e1'), makeEvent('$e2')];
const timeline = makeTimeline(events);
const items = makeItems(2);

const { result, rerender } = renderHook(
({ ver }) =>
useProcessedTimeline({
...defaults,
items,
linkedTimelines: [timeline],
mutationVersion: ver,
}),
{ initialProps: { ver: 0 } }
);

const firstRender = result.current;

// Bump mutation version — stale refs must not be reused
rerender({ ver: 1 });

expect(result.current[0]).not.toBe(firstRender[0]);
expect(result.current[1]).not.toBe(firstRender[1]);
});

it('creates fresh ProcessedEvent objects when itemIndex shifts after back-pagination', () => {
// Initial: one event at index 0
const existingEvent = makeEvent('$existing');
const timelineV1 = makeTimeline([existingEvent]);

const { result, rerender } = renderHook(
({ linkedTimelines, items }: { linkedTimelines: EventTimeline[]; items: number[] }) =>
useProcessedTimeline({
...defaults,
items,
linkedTimelines,
mutationVersion: 0, // unchanged — only the itemIndex changes
}),
{
initialProps: {
linkedTimelines: [timelineV1],
items: [0],
},
}
);

const firstRef = result.current[0];
expect(firstRef.id).toBe('$existing');
expect(firstRef.itemIndex).toBe(0);

// Back-pagination prepends a new event at the front — existing event now at index 1
const newEvent = makeEvent('$new');
const timelineV2 = makeTimeline([newEvent, existingEvent]);

rerender({ linkedTimelines: [timelineV2], items: [0, 1] });

const existingProcessed = result.current.find((e) => e.id === '$existing')!;
// itemIndex must be 1 (updated), NOT 0 (stale from previous render)
expect(existingProcessed.itemIndex).toBe(1);
// And it must be a new object, not the stale cached ref
expect(existingProcessed).not.toBe(firstRef);
});

it('filters events from ignored users', () => {
const events = [
makeEvent('$e1', { sender: '@alice:test' }),
makeEvent('$e2', { sender: '@ignored:test' }),
makeEvent('$e3', { sender: '@alice:test' }),
];
const timeline = makeTimeline(events);

const { result } = renderHook(() =>
useProcessedTimeline({
...defaults,
items: makeItems(3),
linkedTimelines: [timeline],
ignoredUsersSet: new Set(['@ignored:test']),
})
);

const ids = result.current.map((e) => e.id);
expect(ids).not.toContain('$e2');
expect(ids).toContain('$e1');
expect(ids).toContain('$e3');
});

it('places willRenderNewDivider on the event immediately after readUptoEventId', () => {
const events = [
makeEvent('$read', { sender: '@bob:test', ts: 1_000 }),
makeEvent('$new', { sender: '@bob:test', ts: 2_000 }),
];
const timeline = makeTimeline(events);

const { result } = renderHook(() =>
useProcessedTimeline({
...defaults,
items: makeItems(2),
linkedTimelines: [timeline],
mxUserId: '@alice:test', // different from sender so divider renders
readUptoEventId: '$read',
})
);

expect(result.current[1].willRenderNewDivider).toBe(true);
});

it('places willRenderDayDivider between events on different calendar days', () => {
const DAY = 86_400_000;
const events = [
makeEvent('$e1', { ts: 1_000 }),
makeEvent('$e2', { ts: 1_000 + DAY + 1 }), // next day
];
const timeline = makeTimeline(events);

const { result } = renderHook(() =>
useProcessedTimeline({
...defaults,
items: makeItems(2),
linkedTimelines: [timeline],
})
);

expect(result.current[1].willRenderDayDivider).toBe(true);
});
});
Loading
Loading