Skip to content

fix(chat): land on the message you jumped to - #29559

Open
chrisnojima wants to merge 5 commits into
masterfrom
nojima/chat-jump-to-message
Open

fix(chat): land on the message you jumped to#29559
chrisnojima wants to merge 5 commits into
masterfrom
nojima/chat-jump-to-message

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Evaluation branch, split out of nojima/HOTPOT-legends-310 so the jump work can be reviewed without the native-LegendList migration.

The bug

Jumping to a message — a thread-search hit, a reply quote, the pinned banner, a permalink — did not reliably land on it. The app issued its own imperative scroll while the list was separately resolving a target of its own, and the two fought.

The change

  • shared/patches/@legendapp+list+3.3.7.patch — two library fixes: hold an index+viewPosition target across the measurement settle, and follow the end when the header measures after an at-end initial scroll. Both are proven necessary; removing either makes the flows below fail. Source: fix/jump-to-item-scroll-targeting on the vendored fork.
  • useScrollToCentered replaces the old closed-loop centring — one scroll per centred target, per dataset.
  • maintainVisibleContentPosition now passed as the documented boolean rather than a partial config. Identical today (size defaults true), but a partial config opts out of whatever it does not name — which is how maintainScrollAtEnd's {on: {...}} list silently lost a trigger once already.

Most of the list configuration this relies on (alignItemsAtEnd, initialScrollIndex, maintainScrollAtEnd, dataKey) already landed on master; the app-side delta here is small.

Behaviour change riding along

useScrollToCentered drops an else branch the old code had: when the centred ordinal cleared and the thread contained the latest message, the list used to scroll to the end. jumpToRecent is unaffected (it scrolls itself), but closing thread search no longer snaps to the newest message — it leaves the list where the reader was. Deliberate, and called out here because it is a UX change inside a bugfix.

Native

App code for native is untouched — the chat thread still renders FlatList. The runtime is not: the patch changes the native LegendList bundle as well, and the inbox already drives LegendList on native through common-adapters/list.tsx's LegendListNativechat/inbox/list-helpers.tsx and chat/inbox/index.tsx both call scrollToIndex with a viewPosition. So native scroll targeting does change here, and it is unmeasured. If inbox jump-to-unread regresses on device, this is the PR to look at.

Tests

Three desktop end-to-end flows, passing together 3/3 consecutive runs:

  • chat-search-hit — every hit lands, and the thread then stays where the reader put it
  • chat-thread-bottom — opening a conversation lands on its newest message, with row growth deliberately delayed so late measurement can break it
  • chat-link-jumpnew; clicking a message link opens the target conversation landed on that message, which is the only flow covering a thread that mounts with a target already pending. It reuses a link an earlier run left behind rather than sending a new one each time, so running the suite does not accumulate messages in the smoke account's real history.

chat-link-jump asserts the target lands within a third of the viewport of centre, not merely that it is on screen. chat-search-hit is the looser one: it checks visibility only.

yarn lint:all clean: 0 bailouts, 0 whole-props deps, tsc clean both projects.

What a reviewer should look at

The two hooks in list-area/index.tsx and their comments — the ordering and the predicate there are load-bearing and were arrived at by measurement, including one intermediate approach that an e2e test falsified and which was removed.

🤖 Generated with Claude Code

Two library fixes the app cannot get at from outside, generated from
fix/jump-to-item-scroll-targeting off v3.3.7.

scrollTargetSettle: a scrollToIndex/scrollToItem with a viewPosition is aimed at
positions computed from estimatedItemSize, so it lands wherever the estimates above
the target were wrong and never comes back. The list now holds the index +
viewPosition target across the measurement settle and re-aims as rows report their
real heights, which is what makes a search hit or a permalink land on the message
instead of near it.

headerLayout trigger for maintainScrollAtEnd: a header that lays out taller than the
size it was rendered with pushes every item down by the difference, so a list that had
already reached its end is short of it by that much with nothing to bring it back.
Measured opening a one-to-one on desktop: the at-end initial scroll dispatched to 449
against 1228px of content, setHeaderSize reported 100 -> 152 in the same tick, and the
thread sat 52px above its newest message permanently, on roughly half of opens. With
the trigger, 30 of 30 opens landed on the newest message.

Both are load-bearing: removing either makes one of the desktop flows added here fail.
The desktop thread centred a search hit, a reply-quote jump or a permalink with a
closed loop of its own: measure the target row's offset from the viewport centre, ask
for a corrected scroll offset, repeat for up to 3s, and abort on a wheel event. It was
compensating for the library aiming at positions built from estimatedItemSize, and it
compensated badly — it fought maintainVisibleContentPosition for the same scroll
offset, it re-centred a reader who had scrolled away while the loop was still running,
and near either end of a thread it spun against a clamp.

With scrollTargetSettle in the legend-list patch the list holds the target across the
measurement settle itself, so the app's job shrinks to issuing one scroll per target
and getting out of the way. useScrollToCentered does exactly that: wait for the target
to be present in messageOrdinals (a centred load clears the thread first, so it is
briefly absent), scroll once, and reset per dataset rather than per conversation.

The scroll is unconditional rather than deferring to the list's own initialScrollIndex
bootstrap. A permalink mounts the thread with no centred target, so the list is built
with initialScrollAtEnd and the bootstrap it re-arms when the centred dataset lands
does not move it — the thread settles at its end and the target is never shown. That
path is indistinguishable here from a warm in-thread jump, so this has to be the one
authority that always fires.

maintainVisibleContentPosition also becomes the documented boolean. A partial config
object opts out of whatever it does not name, and `data` defaults to false, so
{data: true} was quietly narrowing it the way maintainScrollAtEnd's {on: {...}} list
once did.

Native is untouched: it is still the FlatList thread master ships.
Three flows, all of which were manual checks:

chat-search-hit opens a named conversation (the inbox is ordered by recency, so "the
first row" is a different thread from one run to the next), searches it, and steps
through hits with Enter — the wrap-around at the end is the jump that used to land off
screen. Then it scrolls away and watches: a thread the reader has moved must not pull
itself back once the rows scrolled past finish measuring.

chat-thread-bottom opens conversations and measures how far each settled from its
newest message. Image responses are delayed so the row growth lands after the initial
scroll rather than before it, which is what makes this able to fail rather than
passing on whatever the disk cache had warm. MAX_DISTANCE_FROM_END is 8px and retries
are off on purpose: the growth is timing dependent, and a retry that passes hides the
regression the flow exists for.

chat-link-jump covers the cold path the other two do not — a thread mounting with a
centred target already pending, the way a permalink arrives. It reaches it through the
app's own affordances (copy a link to a message in B, paste and send it in A, click
it), reuses a link an earlier run left behind rather than writing new data into the
smoke user's real history each time, and checks the target both landed on screen and
near the middle.

The testIDs they need are added alongside: the desktop header search button, the
centre-highlighted row, the per-message "..." menu, the big-team channel inbox row,
and the header above the oldest loaded message.
The reuse path never engaged: LINK_PATTERN was anchored without the m flag
but matched against the [data-ordinal] wrapper's innerText, which also carries
the separator and an author header, and the scroll-back wheeled the inbox
because the pointer was never moved into the message list. Every run therefore
sent a fresh link into the smoke account's real chat history - twice per full
suite run, since two projects match flows/**.

Also log the number of leftover link messages so a run shows the count holding,
and give retries: 0 its real reason.

fix(chat): say what actually keeps the centred scroll authoritative

The comment credited the weaker guarantee. Every imperative scroll calls
supersedeInitialScroll synchronously inside runScrollWithPromise, so the app's
call cancels the list bootstrap by construction; note the datasetKey dep too.
Fabric view-flattening drops a Box2 that carries nothing but a testID,
which takes the testID with it and makes the row unfindable to the native
e2e drivers. Desktop ignores the prop, so carrying it costs nothing there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant