Skip to content

Commit 4cd7160

Browse files
committed
fix(timeline): recover after TimelineReset without scroll jumps
1 parent 9087190 commit 4cd7160

3 files changed

Lines changed: 164 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: patch
3+
---
4+
5+
Fix messages disappearing from rooms after reconnects and timeline resets.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { EventEmitter } from 'events';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { Room, RoomEvent } from '$types/matrix-sdk';
5+
import { useTimelineSync } from './useTimelineSync';
6+
7+
vi.mock('@sentry/react', () => ({
8+
default: {},
9+
startSpan: async (_options: unknown, fn: () => Promise<unknown>) => fn(),
10+
addBreadcrumb: vi.fn(),
11+
captureMessage: vi.fn(),
12+
metrics: {
13+
distribution: vi.fn(),
14+
},
15+
}));
16+
17+
type FakeTimeline = {
18+
getEvents: () => unknown[];
19+
getNeighbouringTimeline: () => undefined;
20+
getPaginationToken: () => undefined;
21+
getRoomId: () => string;
22+
};
23+
24+
type FakeTimelineSet = EventEmitter & {
25+
getLiveTimeline: () => FakeTimeline;
26+
getTimelineForEvent: () => undefined;
27+
};
28+
29+
type FakeRoom = Room &
30+
EventEmitter & {
31+
emit: EventEmitter['emit'];
32+
};
33+
34+
function createTimeline(events: unknown[] = [{}]): FakeTimeline {
35+
return {
36+
getEvents: () => events,
37+
getNeighbouringTimeline: () => undefined,
38+
getPaginationToken: () => undefined,
39+
getRoomId: () => '!room:test',
40+
};
41+
}
42+
43+
function createRoom(events: unknown[] = [{}]): {
44+
room: FakeRoom;
45+
timelineSet: FakeTimelineSet;
46+
events: unknown[];
47+
} {
48+
const timeline = createTimeline(events);
49+
const timelineSet = new EventEmitter() as FakeTimelineSet;
50+
timelineSet.getLiveTimeline = () => timeline;
51+
timelineSet.getTimelineForEvent = () => undefined;
52+
53+
const roomEmitter = new EventEmitter();
54+
const room = {
55+
on: roomEmitter.on.bind(roomEmitter),
56+
removeListener: roomEmitter.removeListener.bind(roomEmitter),
57+
emit: roomEmitter.emit.bind(roomEmitter),
58+
roomId: '!room:test',
59+
getUnfilteredTimelineSet: () => timelineSet as never,
60+
getEventReadUpTo: () => null,
61+
getThread: () => null,
62+
client: {
63+
getUserId: () => '@alice:test',
64+
},
65+
} as unknown as FakeRoom;
66+
67+
return { room, timelineSet, events };
68+
}
69+
70+
describe('useTimelineSync', () => {
71+
it('does not snap a non-bottom user to latest after TimelineReset', async () => {
72+
const { room, timelineSet, events } = createRoom();
73+
const scrollToBottom = vi.fn();
74+
75+
renderHook(() =>
76+
useTimelineSync({
77+
room: room as Room,
78+
mx: { getUserId: () => '@alice:test' } as never,
79+
isAtBottom: false,
80+
isAtBottomRef: { current: false },
81+
scrollToBottom,
82+
unreadInfo: undefined,
83+
setUnreadInfo: vi.fn(),
84+
hideReadsRef: { current: false },
85+
readUptoEventIdRef: { current: undefined },
86+
})
87+
);
88+
89+
await act(async () => {
90+
timelineSet.emit(RoomEvent.TimelineReset);
91+
await Promise.resolve();
92+
});
93+
94+
await act(async () => {
95+
events.push({});
96+
room.emit(RoomEvent.LocalEchoUpdated, {}, room);
97+
await Promise.resolve();
98+
});
99+
100+
expect(scrollToBottom).not.toHaveBeenCalled();
101+
});
102+
103+
it('keeps a bottom-pinned user anchored after TimelineReset', async () => {
104+
const { room, timelineSet } = createRoom();
105+
const scrollToBottom = vi.fn();
106+
107+
renderHook(() =>
108+
useTimelineSync({
109+
room: room as Room,
110+
mx: { getUserId: () => '@alice:test' } as never,
111+
isAtBottom: true,
112+
isAtBottomRef: { current: true },
113+
scrollToBottom,
114+
unreadInfo: undefined,
115+
setUnreadInfo: vi.fn(),
116+
hideReadsRef: { current: false },
117+
readUptoEventIdRef: { current: undefined },
118+
})
119+
);
120+
121+
await act(async () => {
122+
timelineSet.emit(RoomEvent.TimelineReset);
123+
await Promise.resolve();
124+
});
125+
126+
expect(scrollToBottom).toHaveBeenCalledWith('instant');
127+
});
128+
});

src/app/hooks/timeline/useTimelineSync.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,11 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
203203
onArriveRef.current = onArrive;
204204

205205
useEffect(() => {
206-
const liveTimeline = getLiveTimeline(room);
207-
const registeredAt = Date.now();
206+
// Both are mutable: if TimelineReset replaces the live EventTimeline object
207+
// we re-anchor them together inside the handler so the isLive check always
208+
// runs against the current timeline and a fresh 60 s backfill window.
209+
let liveTimeline = getLiveTimeline(room);
210+
let registeredAt = Date.now();
208211
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
209212
mEvent: MatrixEvent,
210213
eventRoom: Room | undefined,
@@ -213,6 +216,16 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
213216
data: IRoomTimelineData
214217
) => {
215218
if (eventRoom?.roomId !== room.roomId) return;
219+
220+
// Lazily re-anchor on timeline replacement. Capturing liveTimeline once
221+
// at registration causes events on the new timeline to fail the reference
222+
// check and be silently dropped after a sync gap / reconnect.
223+
const currentLiveTimeline = getLiveTimeline(room);
224+
if (currentLiveTimeline !== liveTimeline) {
225+
liveTimeline = currentLiveTimeline;
226+
registeredAt = Date.now();
227+
}
228+
216229
const { getTs } = mEvent;
217230
const isLive =
218231
data.liveEvent ||
@@ -345,7 +358,7 @@ export function useTimelineSync({
345358
| undefined
346359
>();
347360

348-
const timelineJustResetRef = useRef(false);
361+
const resetAutoScrollPendingRef = useRef(false);
349362

350363
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
351364
const liveTimelineLinked = timeline.linkedTimelines.at(-1) === getLiveTimeline(room);
@@ -485,7 +498,7 @@ export function useTimelineSync({
485498
room,
486499
useCallback(() => {
487500
const wasAtBottom = isAtBottomRef.current;
488-
timelineJustResetRef.current = true;
501+
resetAutoScrollPendingRef.current = wasAtBottom;
489502
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
490503
if (wasAtBottom) {
491504
scrollToBottom('instant');
@@ -508,12 +521,21 @@ export function useTimelineSync({
508521
);
509522

510523
useEffect(() => {
511-
const resetPending = timelineJustResetRef.current;
512-
if (resetPending) timelineJustResetRef.current = false;
513-
514-
if (!(isAtBottom || resetPending) || !liveTimelineLinked || eventsLength === 0) return;
524+
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
525+
if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false;
526+
527+
// liveTimelineLinked can be transiently false after TimelineReset: the SDK
528+
// fires the event before React commits the new linkedTimelines, so the stored
529+
// chain still references the old detached timeline. When auto-scroll recovery
530+
// is pending for a bottom-pinned user, the guard is meaningless lag.
531+
if (
532+
!(isAtBottom || resetAutoScrollPending) ||
533+
(!liveTimelineLinked && !resetAutoScrollPending) ||
534+
eventsLength === 0
535+
)
536+
return;
515537

516-
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetPending) return;
538+
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;
517539

518540
lastScrolledAtEventsLengthRef.current = eventsLength;
519541
scrollToBottom('instant');

0 commit comments

Comments
 (0)