diff --git a/.changeset/fix-timeline-reset-recovery.md b/.changeset/fix-timeline-reset-recovery.md new file mode 100644 index 000000000..ba1f273f7 --- /dev/null +++ b/.changeset/fix-timeline-reset-recovery.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix messages disappearing from rooms after reconnects and timeline resets. diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx new file mode 100644 index 000000000..71fde31c2 --- /dev/null +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -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) => 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'); + }); +}); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 659474d18..395d6fc46 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -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, @@ -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 || @@ -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); @@ -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'); @@ -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');