Skip to content

fix: repair pinned message navigation for large sessions (#187)#188

Open
MCBoarder289 wants to merge 1 commit intowesm:mainfrom
MCBoarder289:pinned-msg-nav-fix
Open

fix: repair pinned message navigation for large sessions (#187)#188
MCBoarder289 wants to merge 1 commit intowesm:mainfrom
MCBoarder289:pinned-msg-nav-fix

Conversation

@MCBoarder289
Copy link
Contributor

@MCBoarder289 MCBoarder289 commented Mar 18, 2026

This PR addresses #187

Navigating to a pinned message in a long session (ascending sort, oldest first) would silently fail — the session would open but the viewport would not reach the target message.

  • Root cause: TanStack Virtual's scrollToIndex reconcile loop exits after
    just 1 stable frame (STABLE_FRAMES=1). When the target item is outside
    the current render window, scrollToIndex scrolls to an estimated
    position. The reconcile then fires once, sees that the estimated offset
    is unchanged (because ResizeObserver measurements are delayed behind
    bumpVersion's setTimeout(0)), declares the position "stable", and
    exits. The viewport lands at the wrong position with no further
    correction.

  • A secondary issue: the virtualizer's postUpdate callback (which
    preserves scroll position during infinite-scroll prepends) would call
    scrollToOffset(currentScrollTop), resetting scrollState.index to null
    and breaking any in-flight reconcile loop early.

  • Fix 1 (MessageList.svelte): after calling scrollToIndex for an
    unrendered target, schedule a retry in 2 animation frames. By then
    ResizeObserver and bumpVersion's setTimeout have fired, updating
    measurementsCache with real item sizes. Each retry either finds the
    item rendered and snaps to its exact measured offset via
    getOffsetForIndex, or calls scrollToIndex again with a more accurate
    estimate. A reqId guard cancels stale retries if a new navigation
    request arrives before the loop completes. Up to 10 render retries
    (~320 ms) are allowed.

  • Fix 2 (createVirtualizer.svelte.ts): skip the postUpdate
    position-preserve when scrollState.index is non-null, i.e. when
    TanStack has an active scrollToIndex reconcile in progress.

Navigating to a pinned message in a long session (ascending sort, oldest first) would silently fail — the session would open but the viewport would not reach the target message.

   Root cause: TanStack Virtual's scrollToIndex reconcile loop exits after
   just 1 stable frame (STABLE_FRAMES=1). When the target item is outside
   the current render window, scrollToIndex scrolls to an estimated
   position. The reconcile then fires once, sees that the estimated offset
   is unchanged (because ResizeObserver measurements are delayed behind
   bumpVersion's setTimeout(0)), declares the position "stable", and
   exits. The viewport lands at the wrong position with no further
   correction.

   A secondary issue: the virtualizer's postUpdate callback (which
   preserves scroll position during infinite-scroll prepends) would call
   scrollToOffset(currentScrollTop), resetting scrollState.index to null
   and breaking any in-flight reconcile loop early.

   Fix 1 (MessageList.svelte): after calling scrollToIndex for an
   unrendered target, schedule a retry in 2 animation frames. By then
   ResizeObserver and bumpVersion's setTimeout have fired, updating
   measurementsCache with real item sizes. Each retry either finds the
   item rendered and snaps to its exact measured offset via
   getOffsetForIndex, or calls scrollToIndex again with a more accurate
   estimate. A reqId guard cancels stale retries if a new navigation
   request arrives before the loop completes. Up to 10 render retries
   (~320 ms) are allowed.

   Fix 2 (createVirtualizer.svelte.ts): skip the postUpdate
   position-preserve when scrollState.index is non-null, i.e. when
   TanStack has an active scrollToIndex reconcile in progress.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@roborev-ci
Copy link

roborev-ci bot commented Mar 18, 2026

roborev: Combined Review (3c858d0)

Summary Verdict: The PR adds request-cancelable retry logic for pinned-message scrolling to prevent clobbering the active reconciliation loop, but requires an adjustment to ensure scroll accuracy in large, variable-height lists.

Medium

Location: frontend/src/lib /components/content/MessageList.svelte:170
Problem: The new isRendered branch assumes getOffsetForIndex() becomes exact as soon as the target row enters the virtual window. In a large, variable-height list, many earlier rows may still be using estimated sizes. This
means scrollToOffset() can stop above or below the real row position, causing pinned-message navigation to remain inaccurate for sessions with significant cumulative height error before the target.
Fix: Keep the scrollToIndex reconcile path active until the row is actually aligned, or gate the scrollToOffset fast path on a stronger condition
than "rendered" (e.g., only after the target and its predecessors have settled/measured, or by checking the rendered element’s actual DOM position before exiting).


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@MCBoarder289 MCBoarder289 marked this pull request as ready for review March 18, 2026 13:35
@MCBoarder289
Copy link
Contributor Author

I'm thinking the review from roborev here isn't too critical. The current/previous behavior was that the navigation fails entirely and viewport stays at the top. Now it navigates to the message, which is rendered and visible.

I think in general this definitely improves the user experience, and if it's off slightly, that's basically on par with how it works today. But in my local testing of it, it seems to be a far better navigation accuracy.

@wesm
Copy link
Owner

wesm commented Mar 18, 2026

Probably not a big deal. I'll take a closer look at this when I can

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.

2 participants