diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx
index bb442d05a..e0de641f1 100644
--- a/src/app/features/room/RoomTimeline.test.tsx
+++ b/src/app/features/room/RoomTimeline.test.tsx
@@ -1,5 +1,5 @@
import { EventEmitter } from 'events';
-import { forwardRef, useImperativeHandle, type ReactNode } from 'react';
+import { forwardRef, useEffect, useImperativeHandle, type ReactNode } from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import type { ProseMirrorEditorController as Editor } from '$components/editor/prosemirrorController';
@@ -22,6 +22,7 @@ const {
windowFocused,
rowItemIndex,
rowRenders,
+ vListMounts,
eventRedacted,
unrenderedJumpTarget,
liveTimeline,
@@ -46,7 +47,6 @@ const {
},
timelineSync: {
eventsLength: 1,
- timelineVersion: 0,
timeline: { linkedTimelines: [] },
liveTimelineLinked: true,
backwardStatus: 'idle',
@@ -77,6 +77,7 @@ const {
windowFocused: { current: false },
rowItemIndex: { current: 0 },
rowRenders: { count: 0 },
+ vListMounts: { count: 0 },
eventRedacted: { current: false },
unrenderedJumpTarget: {
current: undefined as { eventId: string; rawIndex: number } | undefined,
@@ -115,6 +116,9 @@ vi.mock('virtua', () => ({
},
ref
) {
+ useEffect(() => {
+ vListMounts.count += 1;
+ }, []);
lastOnScroll = onScroll;
lastOnScrollEnd = onScrollEnd;
vListProps.shift = shift ?? false;
@@ -394,6 +398,7 @@ beforeEach(() => {
windowFocused.current = false;
rowItemIndex.current = 0;
rowRenders.count = 0;
+ vListMounts.count = 0;
eventRedacted.current = false;
unrenderedJumpTarget.current = undefined;
eventTimeline.current = liveTimeline;
@@ -605,6 +610,17 @@ describe('RoomTimeline content ResizeObserver', () => {
expect(getByText('Jump to Latest')).toBeTruthy();
});
+ it('remounts the virtualizer when switching to a focused timeline window', () => {
+ const { rerender } = renderTimeline();
+ const mounts = vListMounts.count;
+
+ timelineSync.liveTimelineLinked = false;
+ timelineSync.focusItem = { eventId: '$evt1', scrollTo: true, highlight: true };
+ rerender();
+
+ expect(vListMounts.count).toBe(mounts + 1);
+ });
+
it('shifts the virtual list when rendered history prepends', async () => {
timelineSync.liveTimelineLinked = false;
const { rerender } = render();
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 3310d54cf..ba40f2833 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -1321,15 +1321,15 @@ export function RoomTimeline({
if (showLoadingPlaceholders) vListItemCount = 3;
// One row so the error and its Retry have somewhere to render.
else if (showEmptyPaginationError) vListItemCount = 1;
- const vListIndices = useMemo(
- () => Array.from({ length: vListItemCount }, (_, i) => i),
- [vListItemCount]
- );
+ const vListIndices = useMemo(() => {
+ // Keep the cache-busting timeline identity explicit for exhaustive-deps.
+ void timelineSync.timeline;
+ return Array.from({ length: vListItemCount }, (_, i) => i);
+ }, [vListItemCount, timelineSync.timeline]);
const processedEvents = useProcessedTimeline({
items: vListIndices,
linkedTimelines: timelineSync.timeline.linkedTimelines,
- timelineVersion: timelineSync.timelineVersion,
ignoredUsersSet,
hiddenEvents,
mxUserId: mx.getUserId(),
@@ -1473,7 +1473,7 @@ export function RoomTimeline({
>
- key={room.roomId}
+ key={`${room.roomId}:${timelineSync.liveTimelineLinked ? 'live' : (timelineSync.focusItem?.eventId ?? scrollAnchorRef.current)}`}
ref={vListRef}
data={processedEvents}
shift={shouldShift}
diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts
index 7c9d9de9a..f22479d8d 100644
--- a/src/app/hooks/timeline/useProcessedTimeline.ts
+++ b/src/app/hooks/timeline/useProcessedTimeline.ts
@@ -35,8 +35,6 @@ export interface UseProcessedTimelineOptions {
* where every reply legitimately has `threadRootId` set to the root.
*/
skipThreadFilter?: boolean;
- /** Bumped when displayed timeline events mutate in place (redactions, decrypt, reactions). */
- timelineVersion?: number;
}
export interface ProcessedEvent {
@@ -667,7 +665,6 @@ export function useProcessedTimeline({
isReadOnly,
hideMemberInReadOnly,
skipThreadFilter,
- timelineVersion = 0,
}: UseProcessedTimelineOptions): ProcessedEvent[] {
const {
showHiddenEvents,
@@ -682,7 +679,6 @@ export function useProcessedTimeline({
const cacheRef = useRef(undefined);
return useMemo(() => {
- void timelineVersion;
const timelineEvents = flattenTimelineEvents(linkedTimelines);
const processingOptions: TimelineProcessingOptions = {
ignoredUsersSet,
@@ -818,6 +814,5 @@ export function useProcessedTimeline({
isReadOnly,
hideMemberInReadOnly,
skipThreadFilter,
- timelineVersion,
]);
}
diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx
index 00def7e86..900ab9bc9 100644
--- a/src/app/hooks/timeline/useTimelineSync.test.tsx
+++ b/src/app/hooks/timeline/useTimelineSync.test.tsx
@@ -532,7 +532,7 @@ describe('useTimelineSync', () => {
await act(async () => {
emitLiveTimelineEvent(room, timeline, events, '@bob:test');
- await flushRaf();
+ await Promise.resolve();
});
expect(scrollToBottom).toHaveBeenCalledWith('smooth');
@@ -560,7 +560,7 @@ describe('useTimelineSync', () => {
await act(async () => {
emitLiveTimelineEvent(room, timeline, events, '@alice:test');
- await flushRaf();
+ await Promise.resolve();
});
expect(scrollToBottom).toHaveBeenCalledWith('instant');
@@ -591,7 +591,7 @@ describe('useTimelineSync', () => {
await act(async () => {
emitLiveTimelineEvent(room, timeline, events, '@bob:test');
- await flushRaf();
+ await Promise.resolve();
});
expect(setUnreadInfo).toHaveBeenCalledWith(unread);
@@ -1273,7 +1273,7 @@ describe('live-arrive edge cases', () => {
it('renders a stale non-live event appended to the live timeline', async () => {
const { room, timeline, events } = createRoom();
const { result } = renderSyncHook(room);
- const before = result.current.timelineVersion;
+ const before = result.current.timeline;
vi.mocked(isWindowFocused).mockReturnValue(true);
await act(async () => {
await flushRaf();
@@ -1293,7 +1293,7 @@ describe('live-arrive edge cases', () => {
await flushRaf();
});
- expect(result.current.timelineVersion).toBeGreaterThan(before);
+ expect(result.current.timeline).not.toBe(before);
expect(markAsRead).not.toHaveBeenCalled();
vi.mocked(isWindowFocused).mockReturnValue(false);
});
@@ -1301,7 +1301,7 @@ describe('live-arrive edge cases', () => {
it('renders a removal', async () => {
const { room, timeline, events } = createRoom();
const { result } = renderSyncHook(room);
- const before = result.current.timelineVersion;
+ const before = result.current.timeline;
await act(async () => {
events.pop();
@@ -1309,10 +1309,10 @@ describe('live-arrive edge cases', () => {
liveEvent: false,
timeline,
});
- await flushRaf();
+ await Promise.resolve();
});
- expect(result.current.timelineVersion).toBeGreaterThan(before);
+ expect(result.current.timeline).not.toBe(before);
});
it('ignores events emitted for a thread timeline set', async () => {
@@ -1320,7 +1320,7 @@ describe('live-arrive edge cases', () => {
const otherSet = new EventEmitter() as FakeTimelineSet;
const threadTimeline = { ...createTimeline(events), getTimelineSet: () => otherSet };
const { result, scrollToBottom } = renderSyncHook(room);
- const before = result.current.timelineVersion;
+ const before = result.current.timeline;
await act(async () => {
room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, {
@@ -1330,14 +1330,14 @@ describe('live-arrive edge cases', () => {
await Promise.resolve();
});
- expect(result.current.timelineVersion).toBe(before);
+ expect(result.current.timeline).toBe(before);
expect(scrollToBottom).not.toHaveBeenCalled();
});
it('does not treat a threaded reply as an arrival when it lands on the main set', async () => {
const { room, timeline, events } = createRoom();
const { result } = renderSyncHook(room);
- const before = result.current.timelineVersion;
+ const before = result.current.timeline;
vi.mocked(isWindowFocused).mockReturnValue(true);
await act(async () => {
await flushRaf();
@@ -1363,7 +1363,7 @@ describe('live-arrive edge cases', () => {
await flushRaf();
});
- expect(result.current.timelineVersion).toBeGreaterThan(before);
+ expect(result.current.timeline).not.toBe(before);
expect(markAsRead).not.toHaveBeenCalled();
vi.mocked(isWindowFocused).mockReturnValue(false);
});
@@ -1378,7 +1378,7 @@ describe('live-arrive edge cases', () => {
liveEvent: true,
timeline,
});
- await flushRaf();
+ await Promise.resolve();
});
expect(scrollToBottom).toHaveBeenCalledWith('instant');
@@ -1461,7 +1461,7 @@ describe('live-arrive edge cases', () => {
liveEvent: false,
timeline: freshTimeline,
});
- await flushRaf();
+ await Promise.resolve();
});
expect(scrollToBottom).toHaveBeenCalledWith('instant');
});
@@ -1504,7 +1504,7 @@ describe('live-arrive edge cases', () => {
it('re-renders when an event finishes decrypting', async () => {
const { room } = createRoom();
const { result } = renderSyncHook(room);
- const before = result.current.timelineVersion;
+ const before = result.current.timeline;
await act(async () => {
mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId });
@@ -1513,13 +1513,13 @@ describe('live-arrive edge cases', () => {
});
});
- expect(result.current.timelineVersion).toBeGreaterThan(before);
+ expect(result.current.timeline).not.toBe(before);
});
it('ignores decryption of an event in another room', async () => {
const { room } = createRoom();
const { result } = renderSyncHook(room);
- const before = result.current.timelineVersion;
+ const before = result.current.timeline;
await act(async () => {
mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' });
@@ -1528,20 +1528,20 @@ describe('live-arrive edge cases', () => {
});
});
- expect(result.current.timelineVersion).toBe(before);
+ expect(result.current.timeline).toBe(before);
});
it('re-renders when a late local echo updates (slow send acknowledgement)', async () => {
const { room } = createRoom();
const { result } = renderSyncHook(room);
- const before = result.current.timelineVersion;
+ const before = result.current.timeline;
await act(async () => {
room.emit(RoomEvent.LocalEchoUpdated, {}, room);
- await flushRaf();
+ await Promise.resolve();
});
- expect(result.current.timelineVersion).toBeGreaterThan(before);
+ expect(result.current.timeline).not.toBe(before);
});
});
@@ -2409,8 +2409,9 @@ const flushFrame = async () => {
};
describe('decryption refresh coalescing', () => {
+ // Counts distinct timeline objects, not renders: unrelated re-renders reuse the object.
const renderTrackingHook = (room: FakeRoom) => {
- const seen: number[] = [];
+ const seen: unknown[] = [];
renderHook(() => {
const sync = useTimelineSync({
room: room as Room,
@@ -2424,7 +2425,7 @@ describe('decryption refresh coalescing', () => {
readUptoEventIdRef: { current: undefined },
isInactivePanelRef: { current: false },
});
- if (!seen.includes(sync.timelineVersion)) seen.push(sync.timelineVersion);
+ if (!seen.includes(sync.timeline)) seen.push(sync.timeline);
return sync;
});
return seen;
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index 2a4a98a8c..35cf886e5 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -443,8 +443,6 @@ export function useTimelineSync({
const [focusItem, setFocusItem] = useState();
const [jumpFailedFor, setJumpFailedFor] = useState();
const jumpFailed = jumpFailedFor !== undefined && jumpFailedFor === eventId;
- const [timelineVersion, setTimelineVersion] = useState(0);
- const bumpTimeline = useCallback(() => setTimelineVersion((version) => version + 1), []);
const resetAutoScrollPendingRef = useRef(false);
const pendingAutoScrollBehaviorRef = useRef<'instant' | 'smooth' | undefined>(undefined);
@@ -615,22 +613,6 @@ export function useTimelineSync({
[room]
);
- const refreshFrameRef = useRef(undefined);
- const scheduleTimelineRefresh = useCallback(() => {
- if (refreshFrameRef.current !== undefined) return;
- refreshFrameRef.current = requestAnimationFrame(() => {
- refreshFrameRef.current = undefined;
- if (!alive()) return;
- bumpTimeline();
- });
- }, [alive, bumpTimeline]);
- useEffect(
- () => () => {
- if (refreshFrameRef.current !== undefined) cancelAnimationFrame(refreshFrameRef.current);
- },
- []
- );
-
useLiveEventArrive(
room,
useCallback(
@@ -639,7 +621,9 @@ export function useTimelineSync({
const isDisplayedTimeline =
evtTimeline === undefined || linkedTimelinesRef.current.includes(evtTimeline);
- if (isDisplayedTimeline) scheduleTimelineRefresh();
+ if (isDisplayedTimeline) {
+ setActiveTimeline((ct) => ({ ...ct }));
+ }
if (!isLive) return;
@@ -691,10 +675,10 @@ export function useTimelineSync({
setUnreadInfo,
hideReadsRef,
isInactivePanelRef,
+ setActiveTimeline,
focusLiveTimeline,
redactInFocusedWindow,
onReturnToLive,
- scheduleTimelineRefresh,
]
)
);
@@ -702,19 +686,34 @@ export function useTimelineSync({
const handleLocalEchoUpdated = useCallback(
(_mEvent: MatrixEvent, eventRoom: Room | undefined) => {
if (eventRoom?.roomId !== room.roomId) return;
- scheduleTimelineRefresh();
+ setActiveTimeline((ct) => ({ ...ct }));
},
- [room, scheduleTimelineRefresh]
+ [room, setActiveTimeline]
);
useMatrixEvent(room, RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated);
+ const decryptedFrameRef = useRef(undefined);
const handleDecrypted = useCallback(
(mEvent: MatrixEvent) => {
if (mEvent.getRoomId() !== room.roomId) return;
- scheduleTimelineRefresh();
+ if (decryptedFrameRef.current !== undefined) return;
+ decryptedFrameRef.current = requestAnimationFrame(() => {
+ decryptedFrameRef.current = undefined;
+ if (!alive()) return;
+ setActiveTimeline((ct) => ({ ...ct }));
+ });
},
- [room, scheduleTimelineRefresh]
+ [alive, room, setActiveTimeline]
+ );
+
+ useEffect(
+ () => () => {
+ if (decryptedFrameRef.current !== undefined) {
+ cancelAnimationFrame(decryptedFrameRef.current);
+ }
+ },
+ []
);
useMatrixEvent(mx, MatrixEventEvent.Decrypted, handleDecrypted);
@@ -747,7 +746,12 @@ export function useTimelineSync({
)
);
- useThreadUpdate(room, scheduleTimelineRefresh);
+ useThreadUpdate(
+ room,
+ useCallback(() => {
+ setActiveTimeline((ct) => ({ ...ct }));
+ }, [setActiveTimeline])
+ );
useEffect(() => {
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
@@ -769,7 +773,7 @@ export function useTimelineSync({
lastScrolledAtEventsLengthRef.current = eventsLength;
scrollToBottom(behavior);
- }, [isAtBottom, liveTimelineLinked, eventsLength, timelineVersion, scrollToBottom]);
+ }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
useEffect(() => {
if (eventId) return;
@@ -789,7 +793,6 @@ export function useTimelineSync({
return {
timeline,
- timelineVersion,
eventsLength,
liveTimelineLinked,
canPaginateBack,