From ee7eabd2c411361441bdf081e3256560ab597314 Mon Sep 17 00:00:00 2001 From: Lotus Bot Date: Wed, 20 May 2026 21:39:35 -0400 Subject: [PATCH] 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 --- .../components/message/content/PollContent.tsx | 17 +++++++++++++++++ src/app/features/room-nav/RoomNavItem.tsx | 3 ++- src/app/features/room/RoomInput.tsx | 13 +++++++------ src/app/features/room/RoomTimeline.tsx | 9 ++++++--- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 898380417..1aaa5b46f 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text } from 'folds'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; +import { RoomEvent } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; type PollTextValue = Array<{ body: string }> | string; @@ -142,6 +143,21 @@ export function PollContent({ unstableRels?.on(RelationsEvent.Add, refresh); unstableRels?.on(RelationsEvent.Remove, 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 () => { stableRels?.off(RelationsEvent.Add, refresh); stableRels?.off(RelationsEvent.Remove, refresh); @@ -149,6 +165,7 @@ export function PollContent({ unstableRels?.off(RelationsEvent.Add, refresh); unstableRels?.off(RelationsEvent.Remove, refresh); unstableRels?.off(RelationsEvent.Redaction, refresh); + room2?.off(RoomEvent.Timeline, onTimeline); }; }, [mx, roomId, eventId, refresh]); diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 77e4159e2..b9e924e0c 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -245,7 +245,7 @@ type RoomNavItemProps = { showAvatar?: boolean; direct?: boolean; }; -export function RoomNavItem({ +function RoomNavItem_({ room, selected, showAvatar, @@ -440,3 +440,4 @@ export function RoomNavItem({ ); } +export const RoomNavItem = React.memo(RoomNavItem_); \ No newline at end of file diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 6a76c0737..483b6950e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -30,7 +30,7 @@ import { } from 'folds'; 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 { CustomEditor, @@ -56,7 +56,8 @@ import { trimCommand, getMentions, } 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 { TUploadContent, @@ -690,7 +691,7 @@ export const RoomInput = forwardRef( : emojiBtnRef.current?.getBoundingClientRect() ?? undefined } content={ - ( return t; }); }} - /> + /> } > {!hideStickerBtn && ( @@ -758,11 +759,11 @@ export const RoomInput = forwardRef( : undefined } content={ - setGifOpen(false)} - /> + /> } > (() => eventId ? getEmptyTimeline() : getInitialTimeline(room) ); + const timelineRef = React.useRef(timeline); + timelineRef.current = timeline; const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); const liveTimelineLinked = 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 absoluteIndex = - evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId); + evtTimeline && getEventIdAbsoluteIndex(timelineRef.current.linkedTimelines, evtTimeline, evtId); if (typeof absoluteIndex === 'number') { const scrolled = scrollToItem(absoluteIndex, { @@ -678,7 +680,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli loadEventTimeline(evtId); } }, - [room, timeline, scrollToItem, loadEventTimeline] + [room, scrollToItem, loadEventTimeline] ); useLiveTimelineRefresh( @@ -847,13 +849,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }); } - setTimeout(() => { + const timer = setTimeout(() => { if (!alive()) return; setFocusItem((currentItem) => { if (currentItem === focusItem) return undefined; return currentItem; }); }, 2000); + return () => clearTimeout(timer); }, [alive, focusItem, scrollToItem]); // scroll to bottom of timeline