From 32a520cf382913b2e364b4c4536f8d50210a0ef8 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 9 Jul 2026 15:08:41 -0400 Subject: [PATCH 1/2] fix(chat): stop recycled message rows from leaking state and stale heights These are list-agnostic row fixes, split out of the native LegendList work. Desktop already renders the thread with a recycling LegendList, so each of these is a live bug there today; on the native FlatList they are inert guards. - per-row state (sent animation, reaction picker, exploding retained height, ash tower) is now keyed to messageKey. A recycled container reuses the component instance for a different message, so mount-captured state leaked across rows: the picker stayed open on the wrong row, the sent-animation wrapper stuck on, and a measured retain height was applied to the wrong message (and could not self-correct, since retainHeight forces the style height so onLayout only ever reports the forced value back) - recycle-pool suffixes are limited to ones that are stable for the message's lifetime (:failed, :reply). The pool label is recorded when a container is allocated and never updated in place, so :pending (flips on every send confirmation) and :reactions (toggles) left stale labels behind and recycled containers painted at the wrong pooled height - getItemType splits headered rows into their own pool (:hdr). A message that leads its author group is ~40px taller than a grouped follow-on of the same render type, so a shared pool paints recycled views at the wrong height for a frame. It reads the same sticky username cache the rows render with, else a row that keeps its header after a scroll-back load is typed headerless and poisons the headerless pool's height average - useSyncRowLayout: when a row's content settles to a new height after first paint (flip result streams in, reactions appear, an unfurl loads), flush the row measure synchronously so the list's bottom re-pin uses the final height on the same frame instead of a frame late, which otherwise parks the thread above the newest message - SwipeableRow takes an `enabled` prop that conditionally spreads its pan handlers, so a list can shed per-row touch evaluation during a fast fling without unmounting the row. Toggling the Swipeable's subtree instead would remount its children and flash images. No caller passes it yet - desktop LegendList: dataKey replaces the key remount on conversation switch, and maintainScrollAtEnd opts back into footerLayout, which 3.x stopped implying once the trigger set is given explicitly --- shared/chat/conversation/list-area/index.tsx | 41 +++++++++++++++---- .../messages/row-metadata.test.ts | 32 +++++++++++---- .../conversation/messages/row-metadata.tsx | 16 ++++---- .../messages/text/coinflip/index.tsx | 6 +++ .../text/unfurl/unfurl-list/image/index.tsx | 5 +++ .../messages/use-sync-row-layout.tsx | 27 ++++++++++++ .../exploding-height-retainer/index.tsx | 28 ++++++++++--- .../messages/wrapper/sent.native.tsx | 2 + .../conversation/messages/wrapper/wrapper.tsx | 29 ++++++++++--- .../common-adapters/swipeable-row.native.tsx | 3 +- .../common-adapters/swipeable-row.shared.tsx | 3 ++ 11 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 shared/chat/conversation/messages/use-sync-row-layout.tsx diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 255fc90d3db9..1647dec58eb4 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -11,6 +11,7 @@ import {PerfProfiler} from '@/perf/react-profiler' import {ThreadRefsContext} from '../normal/context' import {useConversationCenter} from '../center-context' import { + ShownUsernameCacheContext, useConversationThreadID, useConversationThreadLoadNewerMessagesDueToScroll, useConversationThreadLoadOlderMessagesDueToScroll, @@ -20,7 +21,8 @@ import { } from '../thread-context' import {useJumpToRecent} from './jump-to-recent' import {useThreadLoadStatusOptionsGetter} from '../thread-load-status-context' -import {getMessageRowType} from '../messages/row-metadata' +import {getMessageRowType, getMessageShowUsername} from '../messages/row-metadata' +import {useCurrentUserState} from '@/stores/current-user' import * as InputState from '../input-area/input-state' import sortedIndexOf from 'lodash/sortedIndexOf' import {copyToClipboard} from '@/util/storeless-actions' @@ -47,21 +49,40 @@ const keyExtractor = (ordinal: ItemType) => String(ordinal) // trim off the search-bar lift so the jump button rests ~40px above the bar const jumpAboveBarTrim = 40 -// Item type for list recycling pool separation +// Item type for list recycling pool separation. A message that leads its author group renders an +// avatar + username header (~40px taller) than a grouped follow-on of the same render type. Without +// splitting the pool, recycleItems reuses one container across both heights, so a recycled view +// paints at the wrong height for a frame before re-measure — visible as rows overlapping during +// scroll. Append ':hdr' so header and grouped rows pool separately. const useGetItemType = () => { const threadStore = useConversationThreadStore() + const you = useCurrentUserState(s => s.username) + // Must be the same sticky cache the rows render with (wrapper.tsx): without it, a row that keeps + // its sticky header after a scroll-back load would be typed headerless here, mixing tall headered + // rows into the headerless pool and poisoning that pool's height average. + const shownCache = React.useContext(ShownUsernameCacheContext) return React.useCallback( (ordinal: T.Chat.Ordinal) => { if (!ordinal) { return 'null' } - const {messageMap, messageTypeMap} = threadStore.getState() + const {messageMap, messageTypeMap, messageOrdinals} = threadStore.getState() const message = messageMap.get(ordinal) - return message - ? getMessageRowType(message, messageTypeMap.get(ordinal)) - : (messageTypeMap.get(ordinal) ?? 'text') + if (!message) { + return messageTypeMap.get(ordinal) ?? 'text' + } + const base = getMessageRowType(message, messageTypeMap.get(ordinal)) + const showUsername = getMessageShowUsername({ + message, + messageMap, + messageOrdinals: messageOrdinals ?? noOrdinals, + ordinal, + shownCache, + you, + }) + return showUsername ? `${base}:hdr` : base }, - [threadStore] + [threadStore, you, shownCache] ) } @@ -506,7 +527,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { ref={wrapperRef} > } data={(layoutReady ? messageOrdinals : noOrdinals) as unknown as T.Chat.Ordinal[]} renderItem={renderItem} @@ -521,7 +542,9 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex} maintainScrollAtEnd={ - centeredOrdinal !== undefined ? false : {on: {dataChange: true, itemLayout: true}} + centeredOrdinal !== undefined + ? false + : {on: {dataChange: true, footerLayout: true, itemLayout: true}} } // Stays on while centered: the full thread response lands after the cached one and // re-measures rows above the target, which slides it out of view unless anchored. diff --git a/shared/chat/conversation/messages/row-metadata.test.ts b/shared/chat/conversation/messages/row-metadata.test.ts index 475c62dac9aa..21b2af7fbacd 100644 --- a/shared/chat/conversation/messages/row-metadata.test.ts +++ b/shared/chat/conversation/messages/row-metadata.test.ts @@ -110,7 +110,9 @@ test('showUsername is derived from the previous ordinal and current message data ).toBe('bob') }) -test('row type preserves native recycle distinctions', () => { +test('row type only uses suffixes that are stable for the message lifetime', () => { + // pending flips to confirmed after every send; reactions toggle. Both would leave stale + // recycling-pool labels behind, so they must NOT affect the row type. const pending = makeTextMessage({ id: T.Chat.numberToMessageID(401), ordinal: T.Chat.numberToOrdinal(401), @@ -140,10 +142,10 @@ test('row type preserves native recycle distinctions', () => { reactions: new Map([[':+1:', makeReaction('bob', 5)]]), }) - expect(getMessageRowType(pending)).toBe('text:pending') - expect(getMessageRowType(failed)).toBe('text:pending') + expect(getMessageRowType(pending)).toBe('text') + expect(getMessageRowType(failed)).toBe('text:failed') expect(getMessageRowType(reply)).toBe('text:reply') - expect(getMessageRowType(reaction)).toBe('text:reactions') + expect(getMessageRowType(reaction)).toBe('text') }) test('showUsername recomputes from the current neighboring ordinal after inserts and deletes', () => { @@ -211,7 +213,7 @@ test('showUsername recomputes from the current neighboring ordinal after inserts ).toBe('bob') }) -test('row type combines pending, reply, and reaction suffixes after row edits', () => { +test('row type combines stable suffixes and is unchanged by send confirmation', () => { const reply = makeTextMessage({ id: T.Chat.numberToMessageID(600), ordinal: T.Chat.numberToOrdinal(600), @@ -223,6 +225,13 @@ test('row type combines pending, reply, and reaction suffixes after row edits', replyTo: reply, submitState: 'pending', }) + const failedReply = makeTextMessage({ + errorReason: 'send failed', + id: T.Chat.numberToMessageID(603), + ordinal: T.Chat.numberToOrdinal(603), + replyTo: reply, + submitState: 'failed', + }) const failedAttachment = makeAttachmentMessage({ errorReason: 'upload failed', id: T.Chat.numberToMessageID(602), @@ -230,14 +239,19 @@ test('row type combines pending, reply, and reaction suffixes after row edits', submitState: 'failed', }) - expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:pending:reply:reactions') - expect(getMessageRowType(failedAttachment)).toBe('attachment:pending') + expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:reply') + expect(getMessageRowType(failedReply)).toBe('text:failed:reply') + expect(getMessageRowType(failedAttachment)).toBe('attachment:failed') - const edited = makeTextMessage({ + // confirmation (pending → sent) must not change the type: the recycling pool label was recorded + // at allocation and is never updated in place + const confirmed = makeTextMessage({ id: T.Chat.numberToMessageID(601), ordinal: T.Chat.numberToOrdinal(601), + reactions: new Map([[':+1:', makeReaction('bob', 5)]]), + replyTo: reply, submitState: undefined, }) - expect(getMessageRowType(edited)).toBe('text') + expect(getMessageRowType(confirmed)).toBe('text:reply') }) diff --git a/shared/chat/conversation/messages/row-metadata.tsx b/shared/chat/conversation/messages/row-metadata.tsx index e81b9681d49b..a571c275d25c 100644 --- a/shared/chat/conversation/messages/row-metadata.tsx +++ b/shared/chat/conversation/messages/row-metadata.tsx @@ -70,11 +70,13 @@ export const getMessageRowRecycleType = ( let rowRecycleType = baseType let needsSpecificRecycleType = false - if ( - (message.type === 'text' || message.type === 'attachment') && - (message.submitState === 'pending' || message.submitState === 'failed') - ) { - rowRecycleType += ':pending' + // Only suffixes that are stable for the message's lifetime: the recycling pool label is recorded + // when a container is allocated and never updated on in-place changes, so a suffix that can flip + // (pending → confirmed after every send, reactions toggling on and off) leaves stale pool labels + // behind and recycled containers paint at the wrong pooled height. 'failed' is sticky until an + // explicit retry and 'reply' never changes. + if ((message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed') { + rowRecycleType += ':failed' needsSpecificRecycleType = true } @@ -82,10 +84,6 @@ export const getMessageRowRecycleType = ( rowRecycleType += ':reply' needsSpecificRecycleType = true } - if (message.reactions?.size) { - rowRecycleType += ':reactions' - needsSpecificRecycleType = true - } return needsSpecificRecycleType ? rowRecycleType : undefined } diff --git a/shared/chat/conversation/messages/text/coinflip/index.tsx b/shared/chat/conversation/messages/text/coinflip/index.tsx index 70bc9760be02..0c3f328db588 100644 --- a/shared/chat/conversation/messages/text/coinflip/index.tsx +++ b/shared/chat/conversation/messages/text/coinflip/index.tsx @@ -7,6 +7,7 @@ import {useOrdinal} from '@/chat/conversation/messages/ids-context' import {pluralize} from '@/util/string' import {useConversationThreadMessage, useConversationThreadSelector} from '../../../thread-context' import {useConversationSendActions} from '../../../send-actions' +import {useSyncRowLayout} from '../../use-sync-row-layout' // The flip result arrives via a separate status notification, not with the thread, so on initial // load (an already-finished flip) the card first-paints with no result and then grows when the @@ -47,6 +48,11 @@ function CoinFlipContainer() { const showParticipants = phase === T.RPCChat.UICoinFlipPhase.complete const numParticipants = participants?.length ?? 0 + // The flip result streams in after first paint and grows the card; flush the row measure so the + // list re-pins to the newest message instead of parking above it. Keyed on the status signals + // that change the card height (loaded yet, phase, participant count, result present). + useSyncRowLayout(`${status === undefined ? 0 : 1}|${phase ?? -1}|${numParticipants}|${resultInfo ? 1 : 0}`) + const revealed = participants?.reduce((r, p) => { return r + (p.reveal ? 1 : 0) diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx index 7f4b8513b418..00cd7d397494 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx @@ -4,6 +4,7 @@ import {clampImageSize} from '@/constants/chat/helpers' import {maxWidth} from '@/chat/conversation/messages/attachment/shared' import {Video} from './video' import {openURL} from '@/util/misc' +import {useSyncRowLayout} from '@/chat/conversation/messages/use-sync-row-layout' export type Props = { autoplayVideo: boolean @@ -28,6 +29,10 @@ const UnfurlImage = (p: Props) => { const maxSize = Math.min(maxWidth, 320) - (widthPadding || 0) const {height, width} = clampImageSize(p.width, p.height, maxSize, 320) + // Usually the metadata dimensions are known at first paint, but if they arrive in a later update + // the image grows; flush the row measure so the list re-pins instead of parking above newest. + useSyncRowLayout(`${width}x${height}`) + return isVideo ? (