Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/fix-timeline-reset-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix messages disappearing from rooms after reconnects and timeline resets.
128 changes: 128 additions & 0 deletions src/app/hooks/timeline/useTimelineSync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { EventEmitter } from 'events';
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Room, RoomEvent } from '$types/matrix-sdk';
import { useTimelineSync } from './useTimelineSync';

vi.mock('@sentry/react', () => ({
default: {},
startSpan: async (_options: unknown, fn: () => Promise<unknown>) => fn(),
addBreadcrumb: vi.fn(),
captureMessage: vi.fn(),
metrics: {
distribution: vi.fn(),
},
}));

type FakeTimeline = {
getEvents: () => unknown[];
getNeighbouringTimeline: () => undefined;
getPaginationToken: () => undefined;
getRoomId: () => string;
};

type FakeTimelineSet = EventEmitter & {
getLiveTimeline: () => FakeTimeline;
getTimelineForEvent: () => undefined;
};

type FakeRoom = Room &
EventEmitter & {
emit: EventEmitter['emit'];
};

function createTimeline(events: unknown[] = [{}]): FakeTimeline {
return {
getEvents: () => events,
getNeighbouringTimeline: () => undefined,
getPaginationToken: () => undefined,
getRoomId: () => '!room:test',
};
}

function createRoom(events: unknown[] = [{}]): {
room: FakeRoom;
timelineSet: FakeTimelineSet;
events: unknown[];
} {
const timeline = createTimeline(events);
const timelineSet = new EventEmitter() as FakeTimelineSet;
timelineSet.getLiveTimeline = () => timeline;
timelineSet.getTimelineForEvent = () => undefined;

const roomEmitter = new EventEmitter();
const room = {
on: roomEmitter.on.bind(roomEmitter),
removeListener: roomEmitter.removeListener.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
roomId: '!room:test',
getUnfilteredTimelineSet: () => timelineSet as never,
getEventReadUpTo: () => null,
getThread: () => null,
client: {
getUserId: () => '@alice:test',
},
} as unknown as FakeRoom;

return { room, timelineSet, events };
}

describe('useTimelineSync', () => {
it('does not snap a non-bottom user to latest after TimelineReset', async () => {
const { room, timelineSet, events } = createRoom();
const scrollToBottom = vi.fn();

renderHook(() =>
useTimelineSync({
room: room as Room,
mx: { getUserId: () => '@alice:test' } as never,
isAtBottom: false,
isAtBottomRef: { current: false },
scrollToBottom,
unreadInfo: undefined,
setUnreadInfo: vi.fn(),
hideReadsRef: { current: false },
readUptoEventIdRef: { current: undefined },
})
);

await act(async () => {
timelineSet.emit(RoomEvent.TimelineReset);
await Promise.resolve();
});

await act(async () => {
events.push({});
room.emit(RoomEvent.LocalEchoUpdated, {}, room);
await Promise.resolve();
});

expect(scrollToBottom).not.toHaveBeenCalled();
});

it('keeps a bottom-pinned user anchored after TimelineReset', async () => {
const { room, timelineSet } = createRoom();
const scrollToBottom = vi.fn();

renderHook(() =>
useTimelineSync({
room: room as Room,
mx: { getUserId: () => '@alice:test' } as never,
isAtBottom: true,
isAtBottomRef: { current: true },
scrollToBottom,
unreadInfo: undefined,
setUnreadInfo: vi.fn(),
hideReadsRef: { current: false },
readUptoEventIdRef: { current: undefined },
})
);

await act(async () => {
timelineSet.emit(RoomEvent.TimelineReset);
await Promise.resolve();
});

expect(scrollToBottom).toHaveBeenCalledWith('instant');
});
});
40 changes: 31 additions & 9 deletions src/app/hooks/timeline/useTimelineSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,11 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
onArriveRef.current = onArrive;

useEffect(() => {
const liveTimeline = getLiveTimeline(room);
const registeredAt = Date.now();
// Both are mutable: if TimelineReset replaces the live EventTimeline object
// we re-anchor them together inside the handler so the isLive check always
// runs against the current timeline and a fresh 60 s backfill window.
let liveTimeline = getLiveTimeline(room);
let registeredAt = Date.now();
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
mEvent: MatrixEvent,
eventRoom: Room | undefined,
Expand All @@ -213,6 +216,16 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
data: IRoomTimelineData
) => {
if (eventRoom?.roomId !== room.roomId) return;

// Lazily re-anchor on timeline replacement. Capturing liveTimeline once
// at registration causes events on the new timeline to fail the reference
// check and be silently dropped after a sync gap / reconnect.
const currentLiveTimeline = getLiveTimeline(room);
if (currentLiveTimeline !== liveTimeline) {
liveTimeline = currentLiveTimeline;
registeredAt = Date.now();
}

const { getTs } = mEvent;
const isLive =
data.liveEvent ||
Expand Down Expand Up @@ -345,7 +358,7 @@ export function useTimelineSync({
| undefined
>();

const timelineJustResetRef = useRef(false);
const resetAutoScrollPendingRef = useRef(false);

const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const liveTimelineLinked = timeline.linkedTimelines.at(-1) === getLiveTimeline(room);
Expand Down Expand Up @@ -485,7 +498,7 @@ export function useTimelineSync({
room,
useCallback(() => {
const wasAtBottom = isAtBottomRef.current;
timelineJustResetRef.current = true;
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
scrollToBottom('instant');
Expand All @@ -508,12 +521,21 @@ export function useTimelineSync({
);

useEffect(() => {
const resetPending = timelineJustResetRef.current;
if (resetPending) timelineJustResetRef.current = false;

if (!(isAtBottom || resetPending) || !liveTimelineLinked || eventsLength === 0) return;
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false;

// liveTimelineLinked can be transiently false after TimelineReset: the SDK
// fires the event before React commits the new linkedTimelines, so the stored
// chain still references the old detached timeline. When auto-scroll recovery
// is pending for a bottom-pinned user, the guard is meaningless lag.
if (
!(isAtBottom || resetAutoScrollPending) ||
(!liveTimelineLinked && !resetAutoScrollPending) ||
eventsLength === 0
)
return;

if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetPending) return;
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;

lastScrolledAtEventsLengthRef.current = eventsLength;
scrollToBottom('instant');
Expand Down
Loading