fix(mobile): forget queued-row heights for messages that left the queue

The queued list cached each row's height by message id for the life of the
chat, so a long conversation kept one entry per message ever queued and a
requeued id could place a failed row by the height it left with. Keep heights
only for queued ids, and drop a layout a departed row reports late.
This commit is contained in:
Jinjing
2026-11-12 19:59:05 -08:00
parent 96bad75a6d
commit bd443ce68c
2 changed files with 76 additions and 0 deletions
@@ -431,6 +431,60 @@ describe('MobileNativeChatQueuedMessages', () => {
}
})
it('places a failed row by the height a returning message lays out at, not the one it left with', async () => {
const scrollTo = vi.fn()
const mounted = create(createElement('View'), {
createNodeMock: (node) =>
node.type === 'ScrollView'
? { scrollTo, scrollToEnd: vi.fn(), flashScrollIndicators: vi.fn() }
: null
})
renderer = mounted
// Rows render in queue order.
const layRow = async (index: number, height: number) => {
const row = rows(mounted)[index]!
await act(async () => row.props.onLayout({ nativeEvent: { layout: { height } } }))
}
await act(async () =>
mounted.update(
element({
cards: [card({ messageId: 'a' }), card({ messageId: 'b' }), card({ messageId: 'c' })]
})
)
)
const scroll = mounted.root.find((node) => String(node.type) === 'ScrollView')
await act(async () => {
scroll.props.onLayout({ nativeEvent: { layout: { height: 100 } } })
scroll.props.onContentSizeChange(0, 300)
})
// b leaves tall (an open editor or expanded text), then comes back as a plain 60pt row.
await layRow(0, 60)
await layRow(1, 180)
await layRow(2, 60)
await act(async () =>
mounted.update(element({ cards: [card({ messageId: 'a' }), card({ messageId: 'c' })] }))
)
scrollTo.mockClear()
await act(async () =>
mounted.update(
element({
cards: [
card({ messageId: 'a' }),
card({ messageId: 'b' }),
card({ messageId: 'c', needsAttention: true, caption: "Couldn't send" })
]
})
)
)
// The failed row reports before b does; b's old height must not place it.
await layRow(2, 60)
expect(scrollTo).not.toHaveBeenCalled()
await layRow(1, 60)
// c spans 120–180 of a 100pt list: the nearest scroll that shows it, clear of the edge fade.
expect(scrollTo).toHaveBeenCalledTimes(1)
expect(scrollTo).toHaveBeenCalledWith({ y: 96, animated: false })
})
it('scrolls to a failure the list mounts open for, and settles when no row lays out again', async () => {
vi.useFakeTimers()
try {
@@ -31,6 +31,10 @@ export const QUEUED_ROW_SHOW_SETTLE_MS = 300
type ShowRow = (row: LayoutRectangle) => void
// Message ids cannot contain NUL.
const ID_SEPARATOR = '\u0000'
const NO_IDS: ReadonlySet<string> = new Set()
/**
* Scrolls the queue to one row (a failure that opened the list) once that row has laid out again.
* A row whose height never changes reports no layout, so the request settles shortly after either way.
@@ -51,6 +55,20 @@ export function useQueuedRowShow(
useLayoutEffect(() => {
order.current = messageIds
})
// Only queued rows keep a height, so a long conversation holds none for messages that left, and
// an id that comes back (a requeued draft) waits for its own fresh layout.
const liveKey = messageIds.join(ID_SEPARATOR)
// Filled before any row lays out: layout effects run before native layout events.
const live = useRef(NO_IDS)
useLayoutEffect(() => {
live.current = new Set(liveKey ? liveKey.split(ID_SEPARATOR) : [])
for (const messageId of heights.current.keys()) {
if (!live.current.has(messageId)) {
heights.current.delete(messageId)
fresh.current.delete(messageId)
}
}
}, [liveKey])
useLayoutEffect(() => {
fresh.current.clear()
}, [showId])
@@ -73,6 +91,10 @@ export function useQueuedRowShow(
height: number,
show: ShowRow
) => {
// A row that already left can still report a layout queued before it went.
if (!live.current.has(messageId)) {
return
}
const { showAll } = list
shownAll.current = showAll
heights.current.set(messageId, height)