Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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-bug-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix scroll-to-bottom after room navigation, timeline pagination reliability, and URL preview deduplication.
25 changes: 24 additions & 1 deletion src/app/components/url-preview/UrlPreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,26 @@

const linkStyles = { color: color.Success.Main };

// Module-level in-flight deduplication: prevents N+1 concurrent requests when a
// large event batch renders many UrlPreviewCard instances for the same URL.
// Scoped by MatrixClient to avoid cross-account dedup if multiple clients exist.
// Inner cache keyed by URL only (not ts) — the same URL shows the same preview
// regardless of which message referenced it. Promises are evicted after settling
// so a later render can retry after network recovery.
const previewRequestCache = new WeakMap<any, Map<string, Promise<IPreviewUrlResponse>>>();

const getClientCache = (mx: any): Map<string, Promise<IPreviewUrlResponse>> => {
let clientCache = previewRequestCache.get(mx);
if (!clientCache) {
clientCache = new Map();
previewRequestCache.set(mx, clientCache);
}
return clientCache;
};

const openMediaInNewTab = async (url: string | undefined) => {
if (!url) {
console.warn('Attempted to open an empty url');

Check warning on line 36 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
const blob = await downloadMedia(url);
Expand All @@ -34,7 +51,13 @@
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => {
if (isDirect) return Promise.resolve(null);
return mx.getUrlPreview(url, ts);
const clientCache = getClientCache(mx);
const cached = clientCache.get(url);
if (cached !== undefined) return cached;
const urlPreview = mx.getUrlPreview(url, ts);
clientCache.set(url, urlPreview);
urlPreview.finally(() => clientCache.delete(url));
return urlPreview;
}, [url, ts, mx, isDirect])
);

Expand All @@ -59,14 +82,14 @@
);
const handleAuxClick = (ev: React.MouseEvent) => {
if (!prev['og:image']) {
console.warn('No image');

Check warning on line 85 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
if (ev.button === 1) {
ev.preventDefault();
const mxcUrl = mxcUrlToHttp(mx, prev['og:image'], /* useAuthentication */ true);
if (!mxcUrl) {
console.error('Error converting mxc:// url.');

Check warning on line 92 in src/app/components/url-preview/UrlPreviewCard.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}
openMediaInNewTab(mxcUrl);
Expand Down
80 changes: 71 additions & 9 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ export function RoomTimeline({
const topSpacerHeightRef = useRef(0);
const mountScrollWindowRef = useRef<number>(Date.now() + 3000);
const hasInitialScrolledRef = useRef(false);
// Stored in a ref so eventsLength fluctuations (e.g. onLifecycle timeline reset
// firing within the window) cannot cancel it via useLayoutEffect cleanup.
const initialScrollTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Set to true when the 80 ms timer fires but processedEvents is still empty
// (e.g. the onLifecycle reset cleared the timeline before events refilled it).
// A recovery useLayoutEffect watches for processedEvents becoming non-empty
// and performs the final scroll + setIsReady when this flag is set.
const pendingReadyRef = useRef(false);
const currentRoomIdRef = useRef(room.roomId);

const [isReady, setIsReady] = useState(false);
Expand All @@ -215,6 +223,11 @@ export function RoomTimeline({
hasInitialScrolledRef.current = false;
mountScrollWindowRef.current = Date.now() + 3000;
currentRoomIdRef.current = room.roomId;
pendingReadyRef.current = false;
if (initialScrollTimerRef.current !== undefined) {
clearTimeout(initialScrollTimerRef.current);
initialScrollTimerRef.current = undefined;
}
setIsReady(false);
}

Expand Down Expand Up @@ -270,18 +283,45 @@ export function RoomTimeline({
!eventId &&
!hasInitialScrolledRef.current &&
timelineSync.eventsLength > 0 &&
// Guard: only scroll once the timeline reflects the current room's live
// timeline. Without this, a render with stale data from the previous room
// (before the room-change reset propagates) fires the scroll at the wrong
// position and marks hasInitialScrolledRef = true, preventing the correct
// scroll when the right data arrives.
timelineSync.liveTimelineLinked &&
vListRef.current
) {
vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
const t = setTimeout(() => {
vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
setIsReady(true);
// Store in a ref rather than a local so subsequent eventsLength changes
// (e.g. the onLifecycle timeline reset firing within 80 ms) do NOT
// cancel this timer through the useLayoutEffect cleanup.
initialScrollTimerRef.current = setTimeout(() => {
initialScrollTimerRef.current = undefined;
if (processedEventsRef.current.length > 0) {
vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
// Only mark ready once we've successfully scrolled. If processedEvents
// was empty when the timer fired (e.g. the onLifecycle reset cleared the
// timeline within the 80 ms window), defer setIsReady until the recovery
// effect below fires once events repopulate.
setIsReady(true);
} else {
pendingReadyRef.current = true;
}
}, 80);
hasInitialScrolledRef.current = true;
return () => clearTimeout(t);
}
return () => {};
}, [timelineSync.eventsLength, eventId, room.roomId]);
// No cleanup return — the timer must survive eventsLength fluctuations.
// It is cancelled on unmount by the dedicated effect below.
}, [timelineSync.eventsLength, timelineSync.liveTimelineLinked, eventId, room.roomId]);

// Cancel the initial-scroll timer on unmount (the useLayoutEffect above
// intentionally does not cancel it when deps change).
useEffect(
() => () => {
if (initialScrollTimerRef.current !== undefined) clearTimeout(initialScrollTimerRef.current);
},
[]
);

const recalcTopSpacer = useCallback(() => {
const v = vListRef.current;
Expand Down Expand Up @@ -355,6 +395,11 @@ export function RoomTimeline({

useEffect(() => {
if (eventId) return;
// Guard: once the timeline is visible to the user, do not override their
// scroll position. Without this, a later timeline refresh (e.g. the
// onLifecycle reset delivering a new linkedTimelines reference) can fire
// this effect after isReady and snap the view back to the read marker.
if (isReady) return;
const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
if (readUptoEventId && inLiveTimeline && scrollTo) {
const evtTimeline = getEventTimeline(room, readUptoEventId);
Expand All @@ -366,19 +411,24 @@ export function RoomTimeline({
)
: undefined;

if (absoluteIndex !== undefined && vListRef.current) {
if (absoluteIndex !== undefined) {
const processedIndex = getRawIndexToProcessedIndex(absoluteIndex);
if (processedIndex !== undefined) {
if (processedIndex !== undefined && vListRef.current) {
vListRef.current.scrollToIndex(processedIndex, { align: 'start' });
setUnreadInfo((prev) => (prev ? { ...prev, scrollTo: false } : prev));
}
// Always consume the scroll intent once the event is located in the
// linked timelines, even if its processedIndex is undefined (filtered
// event). Without this, each linkedTimelines reference change retries
// the scroll indefinitely.
setUnreadInfo((prev) => (prev ? { ...prev, scrollTo: false } : prev));
}
}
}, [
room,
unreadInfo,
timelineSync.timeline.linkedTimelines,
eventId,
isReady,
getRawIndexToProcessedIndex,
]);

Expand Down Expand Up @@ -668,6 +718,18 @@ export function RoomTimeline({

processedEventsRef.current = processedEvents;

// Recovery: if the 80 ms initial-scroll timer fired while processedEvents was
// empty (timeline was mid-reset), scroll to bottom and reveal the timeline once
// events repopulate. Fires on every processedEvents.length change but is
// guarded by pendingReadyRef so it only acts once per initial-scroll attempt.
useLayoutEffect(() => {
if (!pendingReadyRef.current) return;
if (processedEvents.length === 0) return;
pendingReadyRef.current = false;
vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' });
setIsReady(true);
}, [processedEvents.length]);

useEffect(() => {
if (!onEditLastMessageRef) return;
const ref = onEditLastMessageRef;
Expand Down
118 changes: 115 additions & 3 deletions src/app/hooks/timeline/useTimelineSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,18 @@ function createTimeline(events: unknown[] = [{}]): FakeTimeline {
};
}

function createRoom(events: unknown[] = [{}]): {
function createRoom(
roomId = '!room:test',
events: unknown[] = [{}]
): {
room: FakeRoom;
timelineSet: FakeTimelineSet;
events: unknown[];
} {
const timeline = createTimeline(events);
const timeline = {
...createTimeline(events),
getRoomId: () => roomId,
};
const timelineSet = new EventEmitter() as FakeTimelineSet;
timelineSet.getLiveTimeline = () => timeline;
timelineSet.getTimelineForEvent = () => undefined;
Expand All @@ -55,7 +61,7 @@ function createRoom(events: unknown[] = [{}]): {
on: roomEmitter.on.bind(roomEmitter),
removeListener: roomEmitter.removeListener.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
roomId: '!room:test',
roomId,
getUnfilteredTimelineSet: () => timelineSet as never,
getEventReadUpTo: () => null,
getThread: () => null,
Expand Down Expand Up @@ -125,4 +131,110 @@ describe('useTimelineSync', () => {

expect(scrollToBottom).toHaveBeenCalledWith('instant');
});

it('resets timeline state when room.roomId changes and eventId is not set', async () => {
const roomOne = createRoom('!room:one');
const roomTwo = createRoom('!room:two');
const scrollToBottom = vi.fn();

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

expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline());

await act(async () => {
rerender({ room: roomTwo.room as Room, eventId: undefined });
await Promise.resolve();
});

expect(result.current.timeline.linkedTimelines[0]).toBe(roomTwo.timelineSet.getLiveTimeline());
});

it('does not reset timeline when eventId is set during a room change', async () => {
const roomOne = createRoom('!room:one');
const roomTwo = createRoom('!room:two');
const scrollToBottom = vi.fn();

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

await act(async () => {
rerender({ room: roomTwo.room as Room, eventId: '$event:one' });
await Promise.resolve();
});

expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline());
});

it('does not reset timeline when the roomId stays the same', async () => {
const roomOne = createRoom('!room:one');
const sameRoomId = createRoom('!room:one');
const scrollToBottom = vi.fn();

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

await act(async () => {
rerender({ room: sameRoomId.room as Room });
await Promise.resolve();
});

expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline());
});
});
Loading
Loading