Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/perf-timeline-item-memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Memoize individual VList timeline items to prevent mass re-renders when unrelated state changes (e.g. typing indicators, read receipts, or new messages while not at the bottom).
179 changes: 165 additions & 14 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Fragment,
ReactNode,
memo,
useCallback,
useEffect,
useLayoutEffect,
Expand Down Expand Up @@ -78,6 +79,50 @@ import { ProcessedEvent, useProcessedTimeline } from '$hooks/timeline/useProcess
import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer';
import * as css from './RoomTimeline.css';

/** Render function type passed to the memoized TimelineItem via a ref. */
type TimelineRenderFn = (eventData: ProcessedEvent) => ReactNode;

/**
* Renders one timeline item. Defined outside RoomTimeline so React never
* recreates the component type, and wrapped in `memo` so it skips re-renders
* when neither the event data nor any per-item volatile state changed.
*
* The actual rendering is delegated to `renderRef.current` (always the latest
* version of `renderMatrixEvent`, set synchronously during each render cycle)
* so stale-closure issues are avoided.
*
* Props not used in the function body (`isHighlighted`, `isEditing`, etc.) are
* intentionally included: React.memo's default shallow-equality comparator
* inspects ALL props, so changing one of them for a specific item causes only
* that item to re-render (e.g. only the message being edited re-renders when
* editId changes).
*/
interface TimelineItemProps {
data: ProcessedEvent;
renderRef: React.MutableRefObject<TimelineRenderFn | null>;
// The props below are not read in the component body — they exist solely so
// React.memo's shallow-equality comparator sees them and re-renders only the
// affected item when they change.
// eslint-disable-next-line react/no-unused-prop-types
isHighlighted: boolean;
// eslint-disable-next-line react/no-unused-prop-types
isEditing: boolean;
// eslint-disable-next-line react/no-unused-prop-types
isReplying: boolean;
// eslint-disable-next-line react/no-unused-prop-types
isOpenThread: boolean;
// eslint-disable-next-line react/no-unused-prop-types
settingsEpoch: object;
}

// Declared outside memo() so the callback receives a reference, not an inline
// function expression (satisfies prefer-arrow-callback).
function TimelineItemInner({ data, renderRef }: TimelineItemProps) {
return <>{renderRef.current?.(data)}</>;
}
const TimelineItem = memo(TimelineItemInner);
TimelineItem.displayName = 'TimelineItem';

const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
<Box
Expand Down Expand Up @@ -220,6 +265,12 @@ export function RoomTimeline({
// A recovery useLayoutEffect watches for processedEvents becoming non-empty
// and performs the final scroll + setIsReady when this flag is set.
const pendingReadyRef = useRef(false);
// Set to true before each programmatic scroll-to-bottom so intermediate
// onScroll events from virtua's height-correction pass cannot drive
// atBottomState to false (flashing the "Jump to Latest" button).
// Cleared when VList confirms isNowAtBottom, or on the first intermediate
// event so subsequent user-initiated scrolls are tracked normally.
const programmaticScrollToBottomRef = useRef(false);
const currentRoomIdRef = useRef(room.roomId);

const [isReady, setIsReady] = useState(false);
Expand All @@ -229,6 +280,7 @@ export function RoomTimeline({
mountScrollWindowRef.current = Date.now() + 3000;
currentRoomIdRef.current = room.roomId;
pendingReadyRef.current = false;
programmaticScrollToBottomRef.current = false;
if (initialScrollTimerRef.current !== undefined) {
clearTimeout(initialScrollTimerRef.current);
initialScrollTimerRef.current = undefined;
Expand All @@ -243,6 +295,9 @@ export function RoomTimeline({
if (!vListRef.current) return;
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex < 0) return;
// Guard against VList's intermediate height-correction scroll events that
// would otherwise call setAtBottom(false) before the scroll settles.
programmaticScrollToBottomRef.current = true;
vListRef.current.scrollTo(vListRef.current.scrollSize);
}, []);

Expand Down Expand Up @@ -303,11 +358,15 @@ export function RoomTimeline({
initialScrollTimerRef.current = setTimeout(() => {
initialScrollTimerRef.current = undefined;
if (processedEventsRef.current.length > 0) {
programmaticScrollToBottomRef.current = true;
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.
// scrollToIndex is async; pre-empt atBottom so the "Jump to Latest"
// button doesn't flash for one render cycle before onScroll confirms.
setAtBottom(true);
setIsReady(true);
} else {
pendingReadyRef.current = true;
Expand All @@ -317,7 +376,13 @@ export function RoomTimeline({
}
// 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]);
}, [
timelineSync.eventsLength,
timelineSync.liveTimelineLinked,
eventId,
room.roomId,
setAtBottom,
]);

// Cancel the initial-scroll timer on unmount (the useLayoutEffect above
// intentionally does not cancel it when deps change).
Expand Down Expand Up @@ -582,6 +647,52 @@ export function RoomTimeline({
utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent },
});

// Render function ref — updated synchronously each render so TimelineItem
// always calls the latest version (which has the current focusItem, editId,
// etc. in its closure) without needing to be a prop dep.
const renderFnRef = useRef<TimelineRenderFn | null>(null);
renderFnRef.current = (eventData: ProcessedEvent) =>
renderMatrixEvent(
eventData.mEvent.getType(),
typeof eventData.mEvent.getStateKey() === 'string',
eventData.id,
eventData.mEvent,
eventData.itemIndex,
eventData.timelineSet,
eventData.collapsed
);

// Object whose identity changes when any global render-affecting setting
// changes. TimelineItem memo sees the new reference and re-renders all items.
const settingsEpoch = useMemo(
() => ({}),
// Any setting that changes how ALL items are rendered should be listed here.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
messageLayout,
messageSpacing,
hideReads,
showDeveloperTools,
hour24Clock,
dateFormatString,
mediaAutoLoad,
showBundledPreview,
showUrlPreview,
showClientUrlPreview,
autoplayStickers,
hideMemberInReadOnly,
isReadOnly,
hideMembershipEvents,
hideNickAvatarEvents,
showHiddenEvents,
reducedMotion,
nicknames,
imagePackRooms,
htmlReactParserOptions,
linkifyOpts,
]
);

const tryAutoMarkAsRead = useCallback(() => {
if (!readUptoEventIdRef.current) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads));
Expand Down Expand Up @@ -620,8 +731,33 @@ export function RoomTimeline({

const distanceFromBottom = v.scrollSize - offset - v.viewportSize;
const isNowAtBottom = distanceFromBottom < 100;
// Clear the programmatic-scroll guard whenever VList confirms we are at the
// bottom, regardless of whether atBottomRef needs updating.
if (isNowAtBottom) programmaticScrollToBottomRef.current = false;
if (isNowAtBottom !== atBottomRef.current) {
setAtBottom(isNowAtBottom);
if (isNowAtBottom || !programmaticScrollToBottomRef.current) {
setAtBottom(isNowAtBottom);
}
// else: programmatic guard active — suppress the false-negative and keep
// the guard set. VList can fire several intermediate "not at bottom"
// events while it corrects item heights after a scrollTo(); clearing the
// guard on the first one would let the second cause a spurious
// setAtBottom(false) and flash the "Jump to Latest" button. The guard
// is cleared above (unconditionally) when isNowAtBottom becomes true.
}

// Keep the scroll cache fresh so the next visit to this room can restore
// position (and skip the 80 ms measurement wait) immediately on mount.
// Skip when viewing a historical slice via eventId: those item heights are
// for a sparse subset of events and would corrupt the cache for the next
// live-timeline visit, producing stale VList measurements and making the
// room appear to be at the wrong position (or visually empty) on re-entry.
if (!eventId) {
roomScrollCache.save(room.roomId, {
cache: v.cache,
scrollOffset: offset,
atBottom: isNowAtBottom,
});
}

if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') {
Expand All @@ -635,11 +771,10 @@ export function RoomTimeline({
timelineSyncRef.current.handleTimelinePagination(false);
}
},
[setAtBottom]
[setAtBottom, room.roomId, eventId]
);

const showLoadingPlaceholders =
timelineSync.eventsLength === 0 &&
(timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading');

let backPaginationJSX: ReactNode | undefined;
Expand Down Expand Up @@ -713,13 +848,20 @@ export function RoomTimeline({
: timelineSync.eventsLength;
const vListIndices = useMemo(
() => Array.from({ length: vListItemCount }, (_, i) => i),
// timelineSync.timeline.linkedTimelines: recompute when the timeline structure
// changes (pagination, room switch). timelineSync.mutationVersion: recompute
// when event content mutates (reactions, edits) without changing the count.
// Using the linkedTimelines reference (not the timeline wrapper object) means
// a setTimeline spread for a live event arrival does NOT recompute this — the
// eventsLength / vListItemCount change already covers that case.
// eslint-disable-next-line react-hooks/exhaustive-deps
[vListItemCount, timelineSync.timeline]
[vListItemCount, timelineSync.timeline.linkedTimelines, timelineSync.mutationVersion]
);

const processedEvents = useProcessedTimeline({
items: vListIndices,
linkedTimelines: timelineSync.timeline.linkedTimelines,
mutationVersion: timelineSync.mutationVersion,
ignoredUsersSet,
showHiddenEvents,
showTombstoneEvents,
Expand All @@ -741,9 +883,13 @@ export function RoomTimeline({
if (!pendingReadyRef.current) return;
if (processedEvents.length === 0) return;
pendingReadyRef.current = false;
programmaticScrollToBottomRef.current = true;
vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' });
// scrollToIndex is async; pre-empt atBottom so the "Jump to Latest" button
// doesn't flash for one render cycle before onScroll confirms the position.
setAtBottom(true);
setIsReady(true);
}, [processedEvents.length]);
}, [processedEvents.length, setAtBottom]);

useEffect(() => {
if (!onEditLastMessageRef) return;
Expand Down Expand Up @@ -890,14 +1036,19 @@ export function RoomTimeline({
return <Fragment key={index} />;
}

const renderedEvent = renderMatrixEvent(
eventData.mEvent.getType(),
typeof eventData.mEvent.getStateKey() === 'string',
eventData.id,
eventData.mEvent,
eventData.itemIndex,
eventData.timelineSet,
eventData.collapsed
const renderedEvent = (
<TimelineItem
data={eventData}
renderRef={renderFnRef}
isHighlighted={
timelineSync.focusItem?.index === eventData.itemIndex &&
(timelineSync.focusItem?.highlight ?? false)
}
isEditing={editId === eventData.mEvent.getId()}
isReplying={activeReplyId === eventData.mEvent.getId()}
isOpenThread={openThreadId === eventData.mEvent.getId()}
settingsEpoch={settingsEpoch}
/>
);

const dividers = (
Expand Down
Loading