Skip to content

Commit a621b5c

Browse files
committed
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 - 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
1 parent b0cdd22 commit a621b5c

9 files changed

Lines changed: 148 additions & 38 deletions

File tree

shared/chat/conversation/list-area/index.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {PerfProfiler} from '@/perf/react-profiler'
1111
import {ThreadRefsContext} from '../normal/context'
1212
import {useConversationCenter} from '../center-context'
1313
import {
14+
ShownUsernameCacheContext,
1415
useConversationThreadID,
1516
useConversationThreadLoadNewerMessagesDueToScroll,
1617
useConversationThreadLoadOlderMessagesDueToScroll,
@@ -20,7 +21,8 @@ import {
2021
} from '../thread-context'
2122
import {useJumpToRecent} from './jump-to-recent'
2223
import {useThreadLoadStatusOptionsGetter} from '../thread-load-status-context'
23-
import {getMessageRowType} from '../messages/row-metadata'
24+
import {getMessageRowType, getMessageShowUsername} from '../messages/row-metadata'
25+
import {useCurrentUserState} from '@/stores/current-user'
2426
import * as InputState from '../input-area/input-state'
2527
import sortedIndexOf from 'lodash/sortedIndexOf'
2628
import {copyToClipboard} from '@/util/storeless-actions'
@@ -47,21 +49,40 @@ const keyExtractor = (ordinal: ItemType) => String(ordinal)
4749
// trim off the search-bar lift so the jump button rests ~40px above the bar
4850
const jumpAboveBarTrim = 40
4951

50-
// Item type for list recycling pool separation
52+
// Item type for list recycling pool separation. A message that leads its author group renders an
53+
// avatar + username header (~40px taller) than a grouped follow-on of the same render type. Without
54+
// splitting the pool, recycleItems reuses one container across both heights, so a recycled view
55+
// paints at the wrong height for a frame before re-measure — visible as rows overlapping during
56+
// scroll. Append ':hdr' so header and grouped rows pool separately.
5157
const useGetItemType = () => {
5258
const threadStore = useConversationThreadStore()
59+
const you = useCurrentUserState(s => s.username)
60+
// Must be the same sticky cache the rows render with (wrapper.tsx): without it, a row that keeps
61+
// its sticky header after a scroll-back load would be typed headerless here, mixing tall headered
62+
// rows into the headerless pool and poisoning that pool's height average.
63+
const shownCache = React.useContext(ShownUsernameCacheContext)
5364
return React.useCallback(
5465
(ordinal: T.Chat.Ordinal) => {
5566
if (!ordinal) {
5667
return 'null'
5768
}
58-
const {messageMap, messageTypeMap} = threadStore.getState()
69+
const {messageMap, messageTypeMap, messageOrdinals} = threadStore.getState()
5970
const message = messageMap.get(ordinal)
60-
return message
61-
? getMessageRowType(message, messageTypeMap.get(ordinal))
62-
: (messageTypeMap.get(ordinal) ?? 'text')
71+
if (!message) {
72+
return messageTypeMap.get(ordinal) ?? 'text'
73+
}
74+
const base = getMessageRowType(message, messageTypeMap.get(ordinal))
75+
const showUsername = getMessageShowUsername({
76+
message,
77+
messageMap,
78+
messageOrdinals: messageOrdinals ?? noOrdinals,
79+
ordinal,
80+
shownCache,
81+
you,
82+
})
83+
return showUsername ? `${base}:hdr` : base
6384
},
64-
[threadStore]
85+
[threadStore, you, shownCache]
6586
)
6687
}
6788

@@ -506,7 +527,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
506527
ref={wrapperRef}
507528
>
508529
<LegendList
509-
key={listKey}
530+
dataKey={listKey}
510531
ref={listRef as React.Ref<LegendListRef>}
511532
data={(layoutReady ? messageOrdinals : noOrdinals) as unknown as T.Chat.Ordinal[]}
512533
renderItem={renderItem}
@@ -521,7 +542,9 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() {
521542
initialScrollAtEnd={initialScrollIndex === undefined}
522543
initialScrollIndex={initialScrollIndex}
523544
maintainScrollAtEnd={
524-
centeredOrdinal !== undefined ? false : {on: {dataChange: true, itemLayout: true}}
545+
centeredOrdinal !== undefined
546+
? false
547+
: {on: {dataChange: true, footerLayout: true, itemLayout: true}}
525548
}
526549
// Stays on while centered: the full thread response lands after the cached one and
527550
// re-measures rows above the target, which slides it out of view unless anchored.

shared/chat/conversation/messages/row-metadata.test.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ test('showUsername is derived from the previous ordinal and current message data
110110
).toBe('bob')
111111
})
112112

113-
test('row type preserves native recycle distinctions', () => {
113+
test('row type only uses suffixes that are stable for the message lifetime', () => {
114+
// pending flips to confirmed after every send; reactions toggle. Both would leave stale
115+
// recycling-pool labels behind, so they must NOT affect the row type.
114116
const pending = makeTextMessage({
115117
id: T.Chat.numberToMessageID(401),
116118
ordinal: T.Chat.numberToOrdinal(401),
@@ -140,10 +142,10 @@ test('row type preserves native recycle distinctions', () => {
140142
reactions: new Map([[':+1:', makeReaction('bob', 5)]]),
141143
})
142144

143-
expect(getMessageRowType(pending)).toBe('text:pending')
144-
expect(getMessageRowType(failed)).toBe('text:pending')
145+
expect(getMessageRowType(pending)).toBe('text')
146+
expect(getMessageRowType(failed)).toBe('text:failed')
145147
expect(getMessageRowType(reply)).toBe('text:reply')
146-
expect(getMessageRowType(reaction)).toBe('text:reactions')
148+
expect(getMessageRowType(reaction)).toBe('text')
147149
})
148150

149151
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
211213
).toBe('bob')
212214
})
213215

214-
test('row type combines pending, reply, and reaction suffixes after row edits', () => {
216+
test('row type combines stable suffixes and is unchanged by send confirmation', () => {
215217
const reply = makeTextMessage({
216218
id: T.Chat.numberToMessageID(600),
217219
ordinal: T.Chat.numberToOrdinal(600),
@@ -223,21 +225,33 @@ test('row type combines pending, reply, and reaction suffixes after row edits',
223225
replyTo: reply,
224226
submitState: 'pending',
225227
})
228+
const failedReply = makeTextMessage({
229+
errorReason: 'send failed',
230+
id: T.Chat.numberToMessageID(603),
231+
ordinal: T.Chat.numberToOrdinal(603),
232+
replyTo: reply,
233+
submitState: 'failed',
234+
})
226235
const failedAttachment = makeAttachmentMessage({
227236
errorReason: 'upload failed',
228237
id: T.Chat.numberToMessageID(602),
229238
ordinal: T.Chat.numberToOrdinal(602),
230239
submitState: 'failed',
231240
})
232241

233-
expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:pending:reply:reactions')
234-
expect(getMessageRowType(failedAttachment)).toBe('attachment:pending')
242+
expect(getMessageRowType(pendingReplyWithReaction)).toBe('text:reply')
243+
expect(getMessageRowType(failedReply)).toBe('text:failed:reply')
244+
expect(getMessageRowType(failedAttachment)).toBe('attachment:failed')
235245

236-
const edited = makeTextMessage({
246+
// confirmation (pending → sent) must not change the type: the recycling pool label was recorded
247+
// at allocation and is never updated in place
248+
const confirmed = makeTextMessage({
237249
id: T.Chat.numberToMessageID(601),
238250
ordinal: T.Chat.numberToOrdinal(601),
251+
reactions: new Map([[':+1:', makeReaction('bob', 5)]]),
252+
replyTo: reply,
239253
submitState: undefined,
240254
})
241255

242-
expect(getMessageRowType(edited)).toBe('text')
256+
expect(getMessageRowType(confirmed)).toBe('text:reply')
243257
})

shared/chat/conversation/messages/row-metadata.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,20 @@ export const getMessageRowRecycleType = (
7070
let rowRecycleType = baseType
7171
let needsSpecificRecycleType = false
7272

73-
if (
74-
(message.type === 'text' || message.type === 'attachment') &&
75-
(message.submitState === 'pending' || message.submitState === 'failed')
76-
) {
77-
rowRecycleType += ':pending'
73+
// Only suffixes that are stable for the message's lifetime: the recycling pool label is recorded
74+
// when a container is allocated and never updated on in-place changes, so a suffix that can flip
75+
// (pending → confirmed after every send, reactions toggling on and off) leaves stale pool labels
76+
// behind and recycled containers paint at the wrong pooled height. 'failed' is sticky until an
77+
// explicit retry and 'reply' never changes.
78+
if ((message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed') {
79+
rowRecycleType += ':failed'
7880
needsSpecificRecycleType = true
7981
}
8082

8183
if (message.type === 'text' && message.replyTo) {
8284
rowRecycleType += ':reply'
8385
needsSpecificRecycleType = true
8486
}
85-
if (message.reactions?.size) {
86-
rowRecycleType += ':reactions'
87-
needsSpecificRecycleType = true
88-
}
8987

9088
return needsSpecificRecycleType ? rowRecycleType : undefined
9189
}

shared/chat/conversation/messages/text/coinflip/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {useOrdinal} from '@/chat/conversation/messages/ids-context'
77
import {pluralize} from '@/util/string'
88
import {useConversationThreadMessage, useConversationThreadSelector} from '../../../thread-context'
99
import {useConversationSendActions} from '../../../send-actions'
10+
import {useSyncRowLayout} from '../../use-sync-row-layout'
1011

1112
// The flip result arrives via a separate status notification, not with the thread, so on initial
1213
// load (an already-finished flip) the card first-paints with no result and then grows when the
@@ -47,6 +48,11 @@ function CoinFlipContainer() {
4748
const showParticipants = phase === T.RPCChat.UICoinFlipPhase.complete
4849
const numParticipants = participants?.length ?? 0
4950

51+
// The flip result streams in after first paint and grows the card; flush the row measure so the
52+
// list re-pins to the newest message instead of parking above it. Keyed on the status signals
53+
// that change the card height (loaded yet, phase, participant count, result present).
54+
useSyncRowLayout(`${status === undefined ? 0 : 1}|${phase ?? -1}|${numParticipants}|${resultInfo ? 1 : 0}`)
55+
5056
const revealed =
5157
participants?.reduce((r, p) => {
5258
return r + (p.reveal ? 1 : 0)

shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {clampImageSize} from '@/constants/chat/helpers'
44
import {maxWidth} from '@/chat/conversation/messages/attachment/shared'
55
import {Video} from './video'
66
import {openURL} from '@/util/misc'
7+
import {useSyncRowLayout} from '@/chat/conversation/messages/use-sync-row-layout'
78

89
export type Props = {
910
autoplayVideo: boolean
@@ -28,6 +29,10 @@ const UnfurlImage = (p: Props) => {
2829
const maxSize = Math.min(maxWidth, 320) - (widthPadding || 0)
2930
const {height, width} = clampImageSize(p.width, p.height, maxSize, 320)
3031

32+
// Usually the metadata dimensions are known at first paint, but if they arrive in a later update
33+
// the image grows; flush the row measure so the list re-pins instead of parking above newest.
34+
useSyncRowLayout(`${width}x${height}`)
35+
3136
return isVideo ? (
3237
<Video
3338
autoPlay={autoplayVideo}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from 'react'
2+
import type * as T from '@/constants/types'
3+
import {useSyncLayout} from '@legendapp/list/react-native'
4+
import {useOrdinal} from './ids-context'
5+
6+
// When a row's content settles to a new height after first paint (a flip result streams in,
7+
// reactions appear, an unfurl loads), force LegendList to re-measure this row synchronously so its
8+
// bottom-anchoring / re-pin uses the final height on the same frame instead of a frame late (which
9+
// otherwise leaves the thread parked above the newest message). Pass a signature that changes when
10+
// the height-affecting content changes. Flushes only when the signature changes for the SAME
11+
// message: the initial mount and a recycled container switching to a new ordinal both get measured
12+
// by LegendList's own onLayout, so flushing there is wasted sync layout work mid-scroll. Noops
13+
// wherever the row is not inside a LegendList container, or on the old architecture.
14+
export const useSyncRowLayout = (signature: string | number) => {
15+
const ordinal = useOrdinal()
16+
const syncLayout = useSyncLayout()
17+
const lastRef = React.useRef<{ordinal: T.Chat.Ordinal; signature: string | number} | undefined>(
18+
undefined
19+
)
20+
React.useLayoutEffect(() => {
21+
const last = lastRef.current
22+
lastRef.current = {ordinal, signature}
23+
if (last?.ordinal !== ordinal) return
24+
if (last.signature === signature) return
25+
syncLayout()
26+
}, [ordinal, signature, syncLayout])
27+
}

shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ const DesktopExplodingHeightRetainer = (p: Props) => {
3131
doneKey: retainHeight ? messageKey : undefined,
3232
retainHeight,
3333
}))
34-
const [height, setHeight] = React.useState(17)
34+
// keyed to the message: with list recycling this instance is reused for other messages, and a
35+
// stale measured height would be retained as the wrong row height
36+
const [heightState, setHeightState] = React.useState(() => ({height: 17, key: messageKey}))
37+
if (heightState.key !== messageKey) {
38+
setHeightState({height: 17, key: messageKey})
39+
}
40+
const height = heightState.height
3541

3642
let currentAnimationState = animationState
3743
if (animationState.retainHeight !== retainHeight) {
@@ -64,7 +70,7 @@ const DesktopExplodingHeightRetainer = (p: Props) => {
6470
const setBoxRef = React.useCallback((ref: Kb.MeasureRef | null) => {
6571
const measuredHeight = ref?.getBoundingClientRect().height
6672
if (measuredHeight) {
67-
setHeight(lastHeight => (lastHeight === measuredHeight ? lastHeight : measuredHeight))
73+
setHeightState(prev => (prev.height === measuredHeight ? prev : {...prev, height: measuredHeight}))
6874
}
6975
}, [])
7076

@@ -162,9 +168,18 @@ function FlameFront(props: {height: number; stop: boolean}) {
162168
// Native implementation
163169
const NativeExplodingHeightRetainer = (p: Props) => {
164170
const {retainHeight, explodedBy, messageKey, style, children} = p
165-
const [height, setHeight] = React.useState(20)
171+
// keyed to the message: with recycleItems this instance is reused for other messages, and a
172+
// stale measured height would be applied as the retained height of the wrong row — and since
173+
// retainHeight forces the style height, onLayout would only ever report the forced value back,
174+
// so it could never self-correct
175+
const [heightState, setHeightState] = React.useState(() => ({height: 20, key: messageKey}))
176+
if (heightState.key !== messageKey) {
177+
setHeightState({height: 20, key: messageKey})
178+
}
179+
const height = heightState.height
166180
const onLayout = (evt: Kb.LayoutEvent) => {
167-
setHeight(evt.nativeEvent.layout.height)
181+
const h = evt.nativeEvent.layout.height
182+
setHeightState(prev => (prev.height === h ? prev : {...prev, height: h}))
168183
}
169184
const numImages = Math.ceil(height / 80)
170185

@@ -181,10 +196,12 @@ const NativeExplodingHeightRetainer = (p: Props) => {
181196
])}
182197
>
183198
{retainHeight ? null : children}
199+
{/* keyed so recycling to a different message remounts the tower — its animation state
200+
(slider value, exploded tag) is per-message */}
184201
<AnimatedAshTower
202+
key={messageKey}
185203
exploded={retainHeight}
186204
explodedBy={explodedBy}
187-
messageKey={messageKey}
188205
numImages={numImages}
189206
/>
190207
</Kb.Box2>
@@ -194,7 +211,6 @@ const NativeExplodingHeightRetainer = (p: Props) => {
194211
type AshTowerProps = {
195212
exploded: boolean
196213
explodedBy?: string
197-
messageKey: string
198214
numImages: number
199215
}
200216

shared/chat/conversation/messages/wrapper/sent.native.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import Animated, {FadeInDown} from 'react-native-reanimated'
44
// Slide-up + fade for a message you just sent. The thread list is an inverted
55
// FlatList (cells are flipped with scaleY: -1), so FadeInDown renders on screen
66
// as sliding up from below. Runs entirely on the UI thread with no re-renders.
7+
// The entering animation only plays when this Animated.View MOUNTS — callers must
8+
// key it per message (recycled containers reuse instances).
79
export function Sent(p: {children: React.ReactNode}) {
810
return (
911
<Animated.View entering={FadeInDown.duration(200)} style={styles.container}>

shared/chat/conversation/messages/wrapper/wrapper.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import ExplodingMeta from './exploding-meta'
1212
import LongPressable from './long-pressable'
1313
import {useMessagePopup} from '../message-popup'
1414
import ReactionsRow from '../reactions-rows'
15+
import {useSyncRowLayout} from '../use-sync-row-layout'
1516
import SendIndicator from './send-indicator'
1617
import * as T from '@/constants/types'
1718
import capitalize from 'lodash/capitalize'
@@ -560,6 +561,9 @@ function TextAndSiblings(p: TSProps) {
560561
const {hasReactions, popupAnchor, reactions, sendIndicatorFailed, sendIndicatorID} = p
561562
const {sendIndicatorSent, type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p
562563
const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker, submitState} = p
564+
// Reactions appearing and an unfurl card loading both grow the row after first paint; flush the
565+
// measure so the list re-pins to the newest message instead of parking above it.
566+
useSyncRowLayout(`${reactions?.size ?? 0}|${hasUnfurlList ? 1 : 0}`)
563567
const pressableProps = isMobile
564568
? {
565569
onLongPress: decorate && shouldShowPopup ? showPopup : undefined,
@@ -913,7 +917,6 @@ function RightSide(p: RProps) {
913917
export function WrapperMessage(p: WrapperMessageProps) {
914918
const {ordinal, bottomChildren, children, messageData: mdata} = p
915919
const {showPopup, showingPopup, popup, popupAnchor} = p
916-
const [showingPicker, setShowingPicker] = React.useState(false)
917920

918921
const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata
919922
const {canShowReactionsPopup, ecrType, exploded, explodesAt, forceExplodingRetainer, messageKey} = mdata
@@ -924,9 +927,23 @@ export function WrapperMessage(p: WrapperMessageProps) {
924927
const {setEditing, setReplyTo, toggleMessageReaction} = mdata
925928
const {author, botAlias, isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} = mdata
926929

927-
// captured at mount: only the row created by your send animates, and the tree
928-
// shape stays stable when the message is later confirmed (youSent flips false)
929-
const [animateSent] = React.useState(isMobile && mdata.youSent)
930+
// Both pieces of per-row state are keyed to messageKey and reset when it changes: with
931+
// recycleItems the component instance is REUSED for a different message, so plain mount-captured
932+
// state would leak across rows (picker open on the wrong row, sent-animation wrapper stuck on).
933+
// Same-message updates keep the captured value, so the tree stays stable when the message is
934+
// later confirmed (youSent flips false).
935+
const [rowState, setRowState] = React.useState(() => ({
936+
animateSent: isMobile && mdata.youSent,
937+
key: messageKey,
938+
showingPicker: false,
939+
}))
940+
if (rowState.key !== messageKey) {
941+
setRowState({animateSent: isMobile && mdata.youSent, key: messageKey, showingPicker: false})
942+
}
943+
const {animateSent, showingPicker} = rowState
944+
const setShowingPicker = React.useCallback((s: boolean) => {
945+
setRowState(prev => (prev.showingPicker === s ? prev : {...prev, showingPicker: s}))
946+
}, [])
930947

931948
const isHighlighted = showCenteredHighlight || isEditing
932949
const tsprops = {
@@ -992,7 +1009,9 @@ export function WrapperMessage(p: WrapperMessageProps) {
9921009

9931010
return (
9941011
<MessageContext value={messageContext}>
995-
{animateSent ? <Sent>{row}</Sent> : row}
1012+
{/* keyed so a recycled container going straight from one of your sends to another remounts
1013+
the Animated.View — the entering animation only plays on mount */}
1014+
{animateSent ? <Sent key={messageKey}>{row}</Sent> : row}
9961015
{popup}
9971016
</MessageContext>
9981017
)

0 commit comments

Comments
 (0)