From acf5c341eed402076faffdfdbef9585f9e399b58 Mon Sep 17 00:00:00 2001 From: nabettu Date: Mon, 2 Nov 2026 13:05:07 +0900 Subject: [PATCH 1/4] fix(mobile): search results open on the matching message A search result opened the thread far from the message. The list scrolled once on estimated row sizes, and measuring the rows there moved the target while the list held the content still. A fractional viewPosition also pushed the start of tall messages off screen, and the jump's own scroll events turned end follow back on. Land until the target stops moving, align the row top with a fixed offset, and keep follow off until the landing is done. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 54 ++++++++++++++++--- .../threads/thread-feed-live-follow.test.ts | 45 ++++++++++++++++ .../threads/thread-feed-live-follow.ts | 22 ++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b43e246fc9c4f..7d4894544932f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -208,6 +208,7 @@ import { import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedLiveFollow, + settleThreadFeedJump, resolveThreadFeedReflowAnchor, resolveThreadFeedReflowOffset, resolveThreadFeedReflowScroll, @@ -292,6 +293,16 @@ const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }); +function nextFrames(count: number): Promise { + return new Promise((resolve) => { + const tick = (remaining: number) => { + if (remaining === 0) resolve(); + else requestAnimationFrame(() => tick(remaining - 1)); + }; + tick(count); + }); +} + function formatMessageTime(input: string): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { @@ -2661,6 +2672,7 @@ export const ThreadFeed = memo(function ThreadFeed({ const reflowSettleTimerRef = useRef | null>(null); const listHeaderSizeRef = useRef(0); const loadedListKeyRef = useRef(null); + const jumpLandingRef = useRef(false); const cancelReflow = () => { if (reflowAnchorRef.current === undefined) return; reflowAnchorRef.current = undefined; @@ -3169,7 +3181,8 @@ export const ThreadFeed = memo(function ThreadFeed({ reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); // Re-arm follow only at the actual end; the broader threshold can reclaim upward drags during streaming. const listState = listRef.current?.getState(); - if (listState) { + // A jump's own scroll events still report the end it is leaving. + if (listState && !jumpLandingRef.current) { transitionEndFollow({ type: "scroll", isAtEnd: listState.isAtEnd, @@ -3274,19 +3287,46 @@ export const ThreadFeed = memo(function ThreadFeed({ ? presentedFeed.findIndex((entry) => entry.type === "message" && entry.id === jumpToMessageId) : null; const followsEnd = endFollowEnabled && !jumpReady; - const landJump = useEffectEvent((index: number) => { - if (index !== -1) { - setEndFollow(false); - void listRef.current?.scrollToIndex({ index, viewPosition: 0.2, animated: false }); + const landJump = useEffectEvent((index: number, cancelled: () => boolean) => { + const finish = () => { + if (cancelled()) return; + jumpLandingRef.current = false; + props.onJumpedToMessage?.(); + }; + const list = listRef.current; + if (index === -1 || !list) { + finish(); + return; } - props.onJumpedToMessage?.(); + jumpLandingRef.current = true; + setEndFollow(false); + void settleThreadFeedJump({ + land: async () => { + // A fractional viewPosition scales with the row, so a message taller + // than the screen would open past its first lines. + await list.scrollToIndex({ + index, + viewPosition: 0, + viewOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET, + animated: false, + }); + await nextFrames(4); + }, + position: () => list.getState().positionAtIndex(index), + cancelled: () => cancelled() || userScrollSessionRef.current, + }).then(finish); }); // Z: a native list scroll after the commit that rendered its target row. The // row is often already measured, so no LegendList callback fires for that // commit, and setEndFollow notifies the parent, so it can't run during render. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the native list to the jump target after the commit that rendered its row useEffect(() => { - if (jumpIndex !== null) landJump(jumpIndex); + if (jumpIndex === null) return; + let cancelled = false; + landJump(jumpIndex, () => cancelled); + return () => { + cancelled = true; + }; // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Jumping to a different message whose row index is unchanged must still land the jump. }, [jumpToMessageId, jumpIndex]); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 728b24419b91d..5196027607ba5 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveThreadFeedLiveFollow, + settleThreadFeedJump, resolveThreadFeedReflowAnchor, resolveThreadFeedReflowOffset, resolveThreadFeedReflowScroll, @@ -310,3 +311,47 @@ describe("width reflow anchors", () => { ).toBeNull(); }); }); + +describe("settleThreadFeedJump", () => { + const landings = (positions: ReadonlyArray) => { + let lands = 0; + return { + land: async () => { + lands += 1; + }, + position: () => positions[Math.min(lands, positions.length - 1)], + lands: () => lands, + }; + }; + + it("lands again until the measured rows stop moving the target", async () => { + // The first landing trusts estimated rows; measuring them moves the target. + const feed = landings([14389, 13563, 13480, 13480]); + await settleThreadFeedJump({ ...feed, cancelled: () => false }); + expect(feed.lands()).toBe(3); + }); + + it("lands once when the target was already in place", async () => { + const feed = landings([500, 500]); + await settleThreadFeedJump({ ...feed, cancelled: () => false }); + expect(feed.lands()).toBe(1); + }); + + it("keeps landing while the target has no position yet", async () => { + const feed = landings([undefined, 700, 700]); + await settleThreadFeedJump({ ...feed, cancelled: () => false }); + expect(feed.lands()).toBe(2); + }); + + it("gives up on a target that never settles", async () => { + const feed = landings(Array.from({ length: 50 }, (_, index) => index * 100)); + await settleThreadFeedJump({ ...feed, cancelled: () => false }); + expect(feed.lands()).toBeLessThan(10); + }); + + it("stops landing once the user takes the scroll", async () => { + const feed = landings([14389, 13563, 13480]); + await settleThreadFeedJump({ ...feed, cancelled: () => feed.lands() >= 1 }); + expect(feed.lands()).toBe(1); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 11865e648e2b9..1e4ee53907036 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -92,6 +92,28 @@ export function resolveThreadFeedLiveFollow( } } +const JUMP_LANDING_LIMIT = 6; + +/** + * Lands on a message until it stops moving. Rows above an unvisited target are + * estimates, so the first landing stops well away from it, and measuring the + * rows there moves the target while the list holds the visible content still. + */ +export async function settleThreadFeedJump(input: { + readonly land: () => Promise; + readonly position: () => number | undefined; + readonly cancelled: () => boolean; +}): Promise { + let previous = input.position(); + for (let landing = 0; landing < JUMP_LANDING_LIMIT; landing += 1) { + await input.land(); + if (input.cancelled()) return; + const next = input.position(); + if (previous !== undefined && next !== undefined && Math.abs(next - previous) <= 1) return; + previous = next; + } +} + /** The LegendList state a width reflow reads before rows re-measure and after they settle. */ export interface ThreadFeedReflowListState { readonly data: ReadonlyArray<{ readonly id: string }>; From 2e8e3576ba0347166045d600aa680af8240777c2 Mon Sep 17 00:00:00 2001 From: nabettu Date: Mon, 2 Nov 2026 13:12:56 +0900 Subject: [PATCH 2/4] fix(mobile): user scrolling ends a search jump for good A drag that finished between landings went unnoticed, so the next landing pulled the user back. A jump cut short also left end follow blocked. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadFeed.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7d4894544932f..617eac0349d83 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2673,6 +2673,7 @@ export const ThreadFeed = memo(function ThreadFeed({ const listHeaderSizeRef = useRef(0); const loadedListKeyRef = useRef(null); const jumpLandingRef = useRef(false); + const jumpOverriddenRef = useRef(false); const cancelReflow = () => { if (reflowAnchorRef.current === undefined) return; reflowAnchorRef.current = undefined; @@ -3199,6 +3200,7 @@ export const ThreadFeed = memo(function ThreadFeed({ const handleScrollBeginDrag = () => { clearUserScrollSettle(); userScrollSessionRef.current = true; + jumpOverriddenRef.current = true; cancelReflow(); // Pause before the first scroll event. Otherwise a stream update can run // maintainScrollAtEnd between touch-down and the drag leaving its threshold. @@ -3235,6 +3237,7 @@ export const ThreadFeed = memo(function ThreadFeed({ // ThreadDetailScreen remounts the feed per thread, so only a send resets following. useImperativeHandle(ref, () => ({ resetEndFollow: () => { + jumpOverriddenRef.current = true; clearUserScrollSettle(); userScrollSessionRef.current = false; cancelReflow(); @@ -3299,6 +3302,7 @@ export const ThreadFeed = memo(function ThreadFeed({ return; } jumpLandingRef.current = true; + jumpOverriddenRef.current = false; setEndFollow(false); void settleThreadFeedJump({ land: async () => { @@ -3313,7 +3317,7 @@ export const ThreadFeed = memo(function ThreadFeed({ await nextFrames(4); }, position: () => list.getState().positionAtIndex(index), - cancelled: () => cancelled() || userScrollSessionRef.current, + cancelled: () => cancelled() || jumpOverriddenRef.current, }).then(finish); }); // Z: a native list scroll after the commit that rendered its target row. The @@ -3326,6 +3330,7 @@ export const ThreadFeed = memo(function ThreadFeed({ landJump(jumpIndex, () => cancelled); return () => { cancelled = true; + jumpLandingRef.current = false; }; // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Jumping to a different message whose row index is unchanged must still land the jump. }, [jumpToMessageId, jumpIndex]); From bcde659600b9d5f340e23be7fde39649b3710e27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 13:53:22 +0000 Subject: [PATCH 3/4] test(mobile): drop a redundant search-jump case Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-feed-scroll-position.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts b/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts index d4ccffb3a3d53..34eabf459ea5f 100644 --- a/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts @@ -113,12 +113,6 @@ describe("settleThreadFeedJump", () => { expect(feed.lands()).toBe(3); }); - it("lands once when the target was already in place", async () => { - const feed = landings([500, 500]); - await settleThreadFeedJump({ ...feed, cancelled: () => false }); - expect(feed.lands()).toBe(1); - }); - it("keeps landing while the target has no position yet", async () => { const feed = landings([undefined, 700, 700]); await settleThreadFeedJump({ ...feed, cancelled: () => false }); From 48c48b4c49c435f71007f300ccf4718fa67743c0 Mon Sep 17 00:00:00 2001 From: nabettu Date: Sun, 15 Nov 2026 09:03:58 +0900 Subject: [PATCH 4/4] fix(mobile): scroll to end stops a landing search jump The scroll-to-end button scrolls without a drag, so a search jump still landing would scroll back to the result. End the jump before scrolling. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index eebc6b8696450..86ce15a772f89 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1411,6 +1411,8 @@ function ThreadDetailBody( const handleScrollToEnd = () => { void Haptics.selectionAsync(); + // A tap does not begin a drag, so end a landing search jump here or it scrolls back. + finishThreadMessageJump(selectedThreadKey); void scrollMessageToEnd({ animated: !reduceMotion, closeKeyboard: false }).catch(() => { freeze.set(false); });