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
20 changes: 18 additions & 2 deletions src/app/features/room/RoomTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +22,7 @@ const {
windowFocused,
rowItemIndex,
rowRenders,
vListMounts,
eventRedacted,
unrenderedJumpTarget,
liveTimeline,
Expand All @@ -46,7 +47,6 @@ const {
},
timelineSync: {
eventsLength: 1,
timelineVersion: 0,
timeline: { linkedTimelines: [] },
liveTimelineLinked: true,
backwardStatus: 'idle',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -115,6 +116,9 @@ vi.mock('virtua', () => ({
},
ref
) {
useEffect(() => {
vListMounts.count += 1;
}, []);
lastOnScroll = onScroll;
lastOnScrollEnd = onScrollEnd;
vListProps.shift = shift ?? false;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(<RoomTimeline room={room} editor={{} as Editor} />);

expect(vListMounts.count).toBe(mounts + 1);
});

it('shifts the virtual list when rendered history prepends', async () => {
timelineSync.liveTimelineLinked = false;
const { rerender } = render(<RoomTimeline room={room} editor={{} as Editor} eventId="$evt1" />);
Expand Down
12 changes: 6 additions & 6 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -1473,7 +1473,7 @@ export function RoomTimeline({
>
<TimelineScrollingProvider value={isTimelineScrolling}>
<VList<ProcessedEvent>
key={room.roomId}
key={`${room.roomId}:${timelineSync.liveTimelineLinked ? 'live' : (timelineSync.focusItem?.eventId ?? scrollAnchorRef.current)}`}
ref={vListRef}
data={processedEvents}
shift={shouldShift}
Expand Down
5 changes: 0 additions & 5 deletions src/app/hooks/timeline/useProcessedTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -667,7 +665,6 @@ export function useProcessedTimeline({
isReadOnly,
hideMemberInReadOnly,
skipThreadFilter,
timelineVersion = 0,
}: UseProcessedTimelineOptions): ProcessedEvent[] {
const {
showHiddenEvents,
Expand All @@ -682,7 +679,6 @@ export function useProcessedTimeline({
const cacheRef = useRef<ProcessingCache | undefined>(undefined);

return useMemo(() => {
void timelineVersion;
const timelineEvents = flattenTimelineEvents(linkedTimelines);
const processingOptions: TimelineProcessingOptions = {
ignoredUsersSet,
Expand Down Expand Up @@ -818,6 +814,5 @@ export function useProcessedTimeline({
isReadOnly,
hideMemberInReadOnly,
skipThreadFilter,
timelineVersion,
]);
}
47 changes: 24 additions & 23 deletions src/app/hooks/timeline/useTimelineSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ describe('useTimelineSync', () => {

await act(async () => {
emitLiveTimelineEvent(room, timeline, events, '@bob:test');
await flushRaf();
await Promise.resolve();
});

expect(scrollToBottom).toHaveBeenCalledWith('smooth');
Expand Down Expand Up @@ -560,7 +560,7 @@ describe('useTimelineSync', () => {

await act(async () => {
emitLiveTimelineEvent(room, timeline, events, '@alice:test');
await flushRaf();
await Promise.resolve();
});

expect(scrollToBottom).toHaveBeenCalledWith('instant');
Expand Down Expand Up @@ -591,7 +591,7 @@ describe('useTimelineSync', () => {

await act(async () => {
emitLiveTimelineEvent(room, timeline, events, '@bob:test');
await flushRaf();
await Promise.resolve();
});

expect(setUnreadInfo).toHaveBeenCalledWith(unread);
Expand Down Expand Up @@ -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();
Expand All @@ -1293,34 +1293,34 @@ 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);
});

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();
room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, true, {
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 () => {
const { room, events } = createRoom();
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, {
Expand All @@ -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();
Expand All @@ -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);
});
Expand All @@ -1378,7 +1378,7 @@ describe('live-arrive edge cases', () => {
liveEvent: true,
timeline,
});
await flushRaf();
await Promise.resolve();
});

expect(scrollToBottom).toHaveBeenCalledWith('instant');
Expand Down Expand Up @@ -1461,7 +1461,7 @@ describe('live-arrive edge cases', () => {
liveEvent: false,
timeline: freshTimeline,
});
await flushRaf();
await Promise.resolve();
});
expect(scrollToBottom).toHaveBeenCalledWith('instant');
});
Expand Down Expand Up @@ -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 });
Expand All @@ -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' });
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Loading
Loading