diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx index 6db9ee48..647de498 100644 --- a/src/app/components/message/RenderBody.tsx +++ b/src/app/components/message/RenderBody.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import parse, { HTMLReactParserOptions } from 'html-react-parser'; import Linkify from 'linkify-react'; import { Opts } from 'linkifyjs'; @@ -21,16 +21,30 @@ export function RenderBody({ htmlReactParserOptions, linkifyOpts, }: RenderBodyProps) { + // `sanitizeCustomHtml` (a full sanitize-html DOM pass) + `parse` are the + // hottest per-render cost on the timeline — RoomTimeline re-renders every + // visible Message on each live event. Both `customBody` and the memoized + // `htmlReactParserOptions` (RoomTimeline.tsx:654) are stable across those + // renders, so caching the parsed tree skips the work for unchanged rows. + const parsedCustomBody = useMemo( + () => (customBody ? parse(sanitizeCustomHtml(customBody), htmlReactParserOptions) : null), + [customBody, htmlReactParserOptions] + ); + // Plaintext path: scaleSystemEmoji walks the string for unicode emoji and + // highlightText re-splits on the search regex — both pure functions of + // (body, highlightRegex), so memoize to match the custom-body fast path. + const plainBody = useMemo( + () => + highlightRegex + ? highlightText(highlightRegex, scaleSystemEmoji(body)) + : scaleSystemEmoji(body), + [body, highlightRegex] + ); + if (body === '') ; if (customBody) { if (customBody === '') ; - return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions); + return parsedCustomBody; } - return ( - - {highlightRegex - ? highlightText(highlightRegex, scaleSystemEmoji(body)) - : scaleSystemEmoji(body)} - - ); + return {plainBody}; } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6c8bc53f..bb5b1d23 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -83,6 +83,7 @@ import { SyslineMessage, EncryptedContent, useMessageInteractionHandlers, + getMessageRenderSig, } from './message'; import { ThreadSummaryCard } from './ThreadSummaryCard'; import { useMemberEventParser } from '../../hooks/useMemberEventParser'; @@ -1274,7 +1275,13 @@ export function RoomTimeline({ // created_ts + expires, we treat the stale join as gone. Linear in // #call-member events (rare); matches the inline `streamRenderableItemHasBefore` // IIFE pattern just below. - const callAnchors: Map = (() => { + // This rebuilds the call-membership state machine by scanning EVERY event in + // EVERY linked timeline (grows unbounded with scrollback). Previously it ran + // as a per-render IIFE, so every fab/atBottom/focus/unread/pulse state change + // re-scanned the whole room. Memoized on the `timeline` state object — the + // only reactive input — so it recomputes only when timeline content changes. + // Date.now() (used below for «ongoing») reads fresh on each actual recompute. + const callAnchors: Map = useMemo(() => { type CallScan = { slotId: string; anchorEventId: string; @@ -1489,7 +1496,7 @@ export function RoomTimeline({ } /* eslint-enable no-restricted-syntax, no-continue */ return anchors; - })(); + }, [timeline]); const renderMatrixEvent = useMatrixEventRenderer< [string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean] @@ -1528,6 +1535,7 @@ export function RoomTimeline({ return ( ( +const MessageInner = as<'div', MessageProps>( ( { className, @@ -1246,6 +1252,107 @@ export const Message = as<'div', MessageProps>( } ); +// Parent-computed render snapshot for a timeline message row. This MUST be +// called during the PARENT's render and threaded in as the `renderSig` prop; +// it must NOT be recomputed from `mEvent` inside the memo comparator. +// +// matrix-js-sdk mutates the `MatrixEvent` IN PLACE on edit / redaction / +// decryption / local-echo (makeReplaced / makeRedacted / setClearData / +// handleRemoteEcho), and the timeline reuses one MatrixEvent instance per id, +// so `prevProps.mEvent === nextProps.mEvent`. A signature read off that live +// object inside the comparator would return the SAME (already-mutated) value +// for both prev and next and could never detect the change -- the classic +// stale-row trap element-web documents on `EventTile` (`isRedacted` is a prop +// there for exactly this reason). Capturing the snapshot here freezes it into +// the prop value, so React's retained prevProps holds the OLD snapshot and the +// comparator can diff old-vs-new. It also folds in the sender display name and +// the urlPreview setting, so a live rename / preview-toggle repaints the row. +export function getMessageRenderSig( + room: Room, + mEvent: MatrixEvent, + editedEvent: MatrixEvent | undefined, + urlPreview: boolean | undefined +): string { + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const content = mEvent.getContent(); + return [ + mEvent.getId() ?? '', + mEvent.getType(), + typeof content.msgtype === 'string' ? content.msgtype : '', + mEvent.isRedacted() ? '1' : '0', + editedEvent?.getId() ?? '', + mEvent.status ?? '', + senderDisplayName, + urlPreview ? '1' : '0', + ].join('\u0001'); +} + +// Custom equality for the timeline row. RoomTimeline re-renders on every live +// event / receipt / scroll-state change and rebuilds all ~80 visible rows; this +// lets React skip rows whose visible output is unchanged. SAFE because the +// dynamic children self-subscribe to their own data (Reactions→useRelations, +// Reply→useRoomEvent, ThreadSummaryCard→ThreadEvent) and the delivery dot +// self-subscribes (useDotColor→RoomEvent.Receipt) — so we only need to compare +// the row's own scalar/identity props, the PRESENCE of the dynamic node slots, +// and the parent-computed renderSig snapshot. The comparator errs toward re-rendering: +// any prop whose identity is unstable simply defeats the memo (no perf win) but +// never causes staleness. Returns true to SKIP the re-render. +// +// The mutable-event vectors (edit / redaction / decryption / local-echo) +// AND the children-only inputs (sender display name, urlPreview) are all +// folded into the parent-computed `renderSig` prop (see getMessageRenderSig), +// so they repaint correctly. `mediaAutoLoad` is mount-time (autoPlay / +// initial fetch), so it needs no signature term. +function areMessagePropsEqual( + prev: ComponentProps, + next: ComponentProps +): boolean { + return ( + prev.room === next.room && + prev.mEvent === next.mEvent && + prev.relations === next.relations && + prev.collapse === next.collapse && + prev.highlight === next.highlight && + prev.edit === next.edit && + prev.canDelete === next.canDelete && + prev.canSendReaction === next.canSendReaction && + prev.canPinEvent === next.canPinEvent && + prev.imagePackRooms === next.imagePackRooms && + prev.hideReadReceipts === next.hideReadReceipts && + prev.showDeveloperTools === next.showDeveloperTools && + prev.memberPowerTag === next.memberPowerTag && + prev.accessibleTagColors === next.accessibleTagColors && + prev.legacyUsernameColor === next.legacyUsernameColor && + prev.streamRailStart === next.streamRailStart && + prev.streamRailEnd === next.streamRailEnd && + prev.hideThreadReplyAffordance === next.hideThreadReplyAffordance && + prev.hideMainReplyAffordance === next.hideMainReplyAffordance && + prev.msgType === next.msgType && + prev.layout === next.layout && + prev.channelHeaderInBubble === next.channelHeaderInBubble && + prev.className === next.className && + prev.onUserClick === next.onUserClick && + prev.onUsernameClick === next.onUsernameClick && + prev.onReplyClick === next.onReplyClick && + prev.onReactionToggle === next.onReactionToggle && + prev.onEditId === next.onEditId && + Boolean(prev.reply) === Boolean(next.reply) && + Boolean(prev.reactions) === Boolean(next.reactions) && + Boolean(prev.threadSummary) === Boolean(next.threadSummary) && + // `data-message-item` is the row's ABSOLUTE virtual-paginator index, queried + // by scrollToItem(). It shifts for existing rows when older history + // paginates in at the top, so a skipped render must not leave it stale — + // live appends at the bottom don't move it, so the memo still fires there. + (prev as Record)['data-message-item'] === + (next as Record)['data-message-item'] && + prev.renderSig === next.renderSig + ); +} + +export const Message = memo(MessageInner, areMessagePropsEqual); + export type EventProps = { room: Room; mEvent: MatrixEvent;