Skip to content

fix(web): stop zero-height measurements from desyncing virtualized lists - #943

Open
sheepbox8646 wants to merge 1 commit into
mainfrom
fix/virtual-list-zero-measure
Open

fix(web): stop zero-height measurements from desyncing virtualized lists#943
sheepbox8646 wants to merge 1 commit into
mainfrom
fix/virtual-list-zero-measure

Conversation

@sheepbox8646

Copy link
Copy Markdown
Member

Problem

The sidebar Recents list would randomly open with a dead gap below the header and only the oldest sessions visible — newly created sessions seemed to disappear. A re-login (full reload) fixed it temporarily; it later came back. Reproduced deterministically on a dev bot's initial load.

Root cause

Rows in measured-row virtualizers can be (re)measured while they cannot be laid out — their pane is display:none behind a v-show'd sidebar view, or the node was just detached by a virtualizer window shift. Those measurements report height 0 and get written into @tanstack/vue-virtual's itemSizeCache.

That poisons the virtualizer twice over:

  1. the spacer collapses (0-height rows), and
  2. the later 0→real re-measure triggers scroll compensation ("content above the fold grew, push scrollTop down") on an element whose scrollTop cannot actually move — applyScrollAdjustment assumes the element.scrollTo() landed and bumps the internal offset anyway, one row height at a time (observed +36px × 18 rows → internal offset 648 while the real scrollTop stayed 0).

The virtualizer then renders its window mid-list: a dead gap at the top where the newest sessions belong, only the tail rows painted. Any real scroll event resyncs it, which is why the bug looked random and self-healing.

Fix

Add measureVirtualRow (apps/web/src/utils/virtual-row-measure.ts), a shared measureElement guard: a zero-height measurement means "unmeasurable right now", so keep the last known size (or the estimate) instead of recording 0. Wired into the three measured-row virtualizers:

  • components/sidebar/recents.vue (the reported list)
  • components/searchable-select-popover/index.vue
  • pages/bots/components/model-options.vue

Verification

  • Playwright against the dev env: before the fix, /bot/hello reproduced on first load (first rendered row index 14 at translateY 288, six 0-height cache entries, +36 adjustment cascade in the scroll log); after the fix, row 0 renders at y=0, full scrollHeight restored, and scroll-to-bottom / panel switch away+back / bot switch while the pane is hidden all stay clean.
  • New unit tests for measureVirtualRow; existing sidebar + chat-list suites pass (139 tests); vue-tsc, ESLint, and the UI contract guard are clean.

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.

🤖 Generated with Claude Code

Rows in measured-row virtualizers (sidebar Recents, model pickers) can be
(re)measured while they cannot be laid out — their pane is display:none
behind a v-show'd sidebar view, or the node was just detached by a window
shift. Those measurements report height 0, and recording them poisons the
virtualizer twice over: the spacer collapses, and the later 0-to-real
re-measure runs scroll compensation on an element whose scrollTop cannot
move, walking the virtualizer's internal offset away from the element's
real scrollTop one row at a time. Recents then opens with a dead gap at
the top and only the oldest sessions visible until a real scroll event
resyncs it.

Add measureVirtualRow, a shared measureElement guard that treats a
zero-height measurement as "unmeasurable right now" and keeps the last
known row size, and wire it into the three measured-row virtualizers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sheepbox8646
sheepbox8646 requested a review from a team as a code owner August 4, 2026 14:57
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