From 7bbd148ff50467cec2025a23f450932d8e011c19 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:46:12 +0100 Subject: [PATCH] fix: keep the mail list where it was when coming back to the Pro mail tab --- components/email/email-list.tsx | 4 ++-- lib/__tests__/virtual-row-measure.test.ts | 26 +++++++++++++++++++++++ lib/virtual-row-measure.ts | 18 ++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/virtual-row-measure.test.ts create mode 100644 lib/virtual-row-measure.ts diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index e4047b635..15e2f7673 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -41,6 +41,7 @@ import { toast } from "@/stores/toast-store"; import { confirmMailRemoval } from "@/lib/mail-removal-confirm"; import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { measureVisibleRow } from "@/lib/virtual-row-measure"; import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; @@ -493,8 +494,7 @@ export function EmailList({ // Keep sub-pixel row heights. The default measurer rounds them, so a // row could start up to half a pixel inside the one above it and paint // over that row's divider (at 125% or 150% display scaling). - measureElement: (element, entry) => - entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height, + measureElement: measureVisibleRow, }); diff --git a/lib/__tests__/virtual-row-measure.test.ts b/lib/__tests__/virtual-row-measure.test.ts new file mode 100644 index 000000000..3e3c9e764 --- /dev/null +++ b/lib/__tests__/virtual-row-measure.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import type { Virtualizer } from '@tanstack/react-virtual'; +import { measureVisibleRow } from '@/lib/virtual-row-measure'; + +const instance = { + measurementsCache: [{ size: 120 }, { size: 80 }], + indexFromElement: () => 1, +} as unknown as Virtualizer; + +const entry = (blockSize: number) => ({ borderBoxSize: [{ blockSize, inlineSize: 300 }] }) as unknown as ResizeObserverEntry; +const row = document.createElement('div'); + +describe('measureVisibleRow', () => { + it('takes the measured height, sub-pixels included', () => { + expect(measureVisibleRow(row, entry(96.5), instance)).toBe(96.5); + }); + + it('keeps the known height of a row in a hidden list', () => { + expect(measureVisibleRow(row, entry(0), instance)).toBe(80); + }); + + it('reports 0 for a hidden row it knows nothing about', () => { + const empty = { measurementsCache: [], indexFromElement: () => 0 } as unknown as Virtualizer; + expect(measureVisibleRow(row, entry(0), empty)).toBe(0); + }); +}); diff --git a/lib/virtual-row-measure.ts b/lib/virtual-row-measure.ts new file mode 100644 index 000000000..a0c9104e6 --- /dev/null +++ b/lib/virtual-row-measure.ts @@ -0,0 +1,18 @@ +import type { Virtualizer } from '@tanstack/react-virtual'; + +/** + * A virtualizer's measureElement with sub-pixel row heights that ignores a row + * measuring 0 because its list is hidden. A Pro tab in the background is + * display:none and its rows report no height: taken at face value the list + * collapses, and coming back it grows row by row and scrolls away from where + * it was. No visible row is 0 high, so the known size stands then. + */ +export function measureVisibleRow( + element: TItem, + entry: ResizeObserverEntry | undefined, + instance: Virtualizer, +): number { + const size = entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height; + if (size > 0) return size; + return instance.measurementsCache[instance.indexFromElement(element)]?.size ?? size; +}