fix(bug,perf): poll first-vote race, stale timeline ref, lazy GifPicker/EmojiBoard, focusItem timer leak, RoomNavItem memo

BUG-18: clearTimeout cleanup in focusItem useLayoutEffect prevents leaked timers
BUG-24: Room timeline listener catches first poll vote before Relations object exists
BUG-25: Use timelineRef.current in handleOpenEvent to prevent stale index on rapid navigation
Perf-6: React.lazy + Suspense for GifPicker and EmojiBoard (initial bundle -114 kB)
Perf-7: React.memo on RoomNavItem to prevent re-renders on unrelated state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lotus Bot
2026-05-20 21:39:35 -04:00
parent 71791e46f6
commit ee7eabd2c4
4 changed files with 32 additions and 10 deletions
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text } from 'folds'; import { Box, Text } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { RoomEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
type PollTextValue = Array<{ body: string }> | string; type PollTextValue = Array<{ body: string }> | string;
@@ -142,6 +143,21 @@ export function PollContent({
unstableRels?.on(RelationsEvent.Add, refresh); unstableRels?.on(RelationsEvent.Add, refresh);
unstableRels?.on(RelationsEvent.Remove, refresh); unstableRels?.on(RelationsEvent.Remove, refresh);
unstableRels?.on(RelationsEvent.Redaction, refresh); unstableRels?.on(RelationsEvent.Redaction, refresh);
// Also listen at room level: if no votes exist yet, the Relations object is null
// and the listeners above are no-ops. The room timeline event catches the first vote.
const onTimeline = (ev: any) => {
const type = ev.getType?.();
const relatesTo = ev.getContent?.()?.['m.relates_to'];
if (
(type === 'm.poll.response' || type === 'org.matrix.msc3381.poll.response') &&
relatesTo?.event_id === eventId
) {
refresh();
}
};
const room2 = mx.getRoom(roomId);
room2?.on(RoomEvent.Timeline, onTimeline);
return () => { return () => {
stableRels?.off(RelationsEvent.Add, refresh); stableRels?.off(RelationsEvent.Add, refresh);
stableRels?.off(RelationsEvent.Remove, refresh); stableRels?.off(RelationsEvent.Remove, refresh);
@@ -149,6 +165,7 @@ export function PollContent({
unstableRels?.off(RelationsEvent.Add, refresh); unstableRels?.off(RelationsEvent.Add, refresh);
unstableRels?.off(RelationsEvent.Remove, refresh); unstableRels?.off(RelationsEvent.Remove, refresh);
unstableRels?.off(RelationsEvent.Redaction, refresh); unstableRels?.off(RelationsEvent.Redaction, refresh);
room2?.off(RoomEvent.Timeline, onTimeline);
}; };
}, [mx, roomId, eventId, refresh]); }, [mx, roomId, eventId, refresh]);
+2 -1
View File
@@ -245,7 +245,7 @@ type RoomNavItemProps = {
showAvatar?: boolean; showAvatar?: boolean;
direct?: boolean; direct?: boolean;
}; };
export function RoomNavItem({ function RoomNavItem_({
room, room,
selected, selected,
showAvatar, showAvatar,
@@ -440,3 +440,4 @@ export function RoomNavItem({
</NavItem> </NavItem>
); );
} }
export const RoomNavItem = React.memo(RoomNavItem_);
+7 -6
View File
@@ -30,7 +30,7 @@ import {
} from 'folds'; } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { GifPicker } from '../../components/GifPicker'; const GifPicker = React.lazy(() => import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })));
import { useClientConfig } from '../../hooks/useClientConfig'; import { useClientConfig } from '../../hooks/useClientConfig';
import { import {
CustomEditor, CustomEditor,
@@ -56,7 +56,8 @@ import {
trimCommand, trimCommand,
getMentions, getMentions,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoardTab } from '../../components/emoji-board/types';
const EmojiBoard = React.lazy(() => import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })));
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { import {
TUploadContent, TUploadContent,
@@ -690,7 +691,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined : emojiBtnRef.current?.getBoundingClientRect() ?? undefined
} }
content={ content={
<EmojiBoard <React.Suspense fallback={null}><EmojiBoard
tab={emojiBoardTab} tab={emojiBoardTab}
onTabChange={setEmojiBoardTab} onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
@@ -707,7 +708,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return t; return t;
}); });
}} }}
/> /></React.Suspense>
} }
> >
{!hideStickerBtn && ( {!hideStickerBtn && (
@@ -758,11 +759,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
: undefined : undefined
} }
content={ content={
<GifPicker <React.Suspense fallback={null}><GifPicker
apiKey={gifApiKey} apiKey={gifApiKey}
onSelect={handleGifSelect} onSelect={handleGifSelect}
requestClose={() => setGifOpen(false)} requestClose={() => setGifOpen(false)}
/> /></React.Suspense>
} }
> >
<IconButton <IconButton
+6 -3
View File
@@ -543,6 +543,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [timeline, setTimeline] = useState<Timeline>(() => const [timeline, setTimeline] = useState<Timeline>(() =>
eventId ? getEmptyTimeline() : getInitialTimeline(room) eventId ? getEmptyTimeline() : getInitialTimeline(room)
); );
const timelineRef = React.useRef(timeline);
timelineRef.current = timeline;
const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
const liveTimelineLinked = const liveTimelineLinked =
timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room); timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
@@ -659,7 +661,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) => { ) => {
const evtTimeline = getEventTimeline(room, evtId); const evtTimeline = getEventTimeline(room, evtId);
const absoluteIndex = const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId); evtTimeline && getEventIdAbsoluteIndex(timelineRef.current.linkedTimelines, evtTimeline, evtId);
if (typeof absoluteIndex === 'number') { if (typeof absoluteIndex === 'number') {
const scrolled = scrollToItem(absoluteIndex, { const scrolled = scrollToItem(absoluteIndex, {
@@ -678,7 +680,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
loadEventTimeline(evtId); loadEventTimeline(evtId);
} }
}, },
[room, timeline, scrollToItem, loadEventTimeline] [room, scrollToItem, loadEventTimeline]
); );
useLiveTimelineRefresh( useLiveTimelineRefresh(
@@ -847,13 +849,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}); });
} }
setTimeout(() => { const timer = setTimeout(() => {
if (!alive()) return; if (!alive()) return;
setFocusItem((currentItem) => { setFocusItem((currentItem) => {
if (currentItem === focusItem) return undefined; if (currentItem === focusItem) return undefined;
return currentItem; return currentItem;
}); });
}, 2000); }, 2000);
return () => clearTimeout(timer);
}, [alive, focusItem, scrollToItem]); }, [alive, focusItem, scrollToItem]);
// scroll to bottom of timeline // scroll to bottom of timeline