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,