Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 41 additions & 16 deletions frontend/src/lib/components/content/MessageList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@
function scrollToDisplayIndex(
index: number,
attempt: number = 0,
reqId: number = lastScrollRequest,
) {
if (reqId !== lastScrollRequest) return;

const v = virtualizer.instance;
if (!v) return;

Expand All @@ -157,28 +160,50 @@
(virtualCount !== desiredCount || index >= virtualCount)
) {
requestAnimationFrame(() => {
scrollToDisplayIndex(index, attempt + 1);
scrollToDisplayIndex(index, attempt + 1, reqId);
});
return;
}

// TanStack's scrollToIndex may continuously re-seek
// in dynamic mode. Use one offset seek to avoid
// visible scroll "fight."
const offsetAndAlign =
v.getOffsetForIndex(index, "start");
if (offsetAndAlign) {
const [offset] = offsetAndAlign;
v.scrollToOffset(
Math.round(offset),
{ align: "start" },
);
// If the item is already rendered (in the current virtual window),
// use its exact measured offset. Predecessor sizes are known so
// getOffsetForIndex is accurate.
const virtualItems = v.getVirtualItems();
const isRendered = virtualItems.some(
(vi) => vi.index === index,
);
if (isRendered) {
const offsetAndAlign =
v.getOffsetForIndex(index, "start");
if (offsetAndAlign) {
const [offset] = offsetAndAlign;
v.scrollToOffset(
Math.round(offset),
{ align: "start" },
);
}
return;
}

// Item not yet measured — use scrollToIndex which will
// estimate and then correct once measured.
// Item not yet in render window. scrollToIndex scrolls to an
// estimated position, but TanStack's reconcile loop exits after
// 1 stable frame — before ResizeObserver measurements (delayed
// by bumpVersion's setTimeout(0)) have updated the offsets. The
// scroll stops at an estimated position rather than the real one.
//
// Retry in 2 frames: by then ResizeObserver + bumpVersion have
// fired, measurements are updated, and the next attempt either
// finds the item rendered (for an exact offset scroll) or repeats
// with a more accurate estimate. Limit to 10 render retries
// (~320 ms) to avoid looping forever.
v.scrollToIndex(index, { align: "start" });
if (attempt < 15) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToDisplayIndex(index, attempt + 1, reqId);
});
});
}
}

function raf(): Promise<void> {
Expand All @@ -195,7 +220,7 @@
const idx = ui.sortNewestFirst
? displayItemsAsc.length - 1 - idxAsc
: idxAsc;
scrollToDisplayIndex(idx);
scrollToDisplayIndex(idx, 0, reqId);
return;
}

Expand All @@ -217,7 +242,7 @@
const loadedIdx = ui.sortNewestFirst
? displayItemsAsc.length - 1 - loadedIdxAsc
: loadedIdxAsc;
scrollToDisplayIndex(loadedIdx);
scrollToDisplayIndex(loadedIdx, 0, reqId);
}

export function scrollToOrdinal(ordinal: number) {
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/virtual/createVirtualizer.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ export function createVirtualizer(
scrollEl.scrollTop = 0;
return;
}
// Don't override an active scrollToIndex reconcile loop.
// scrollState.index is non-null while TanStack is iterating
// toward the target index. Calling scrollToOffset here would
// reset scrollState.index to null, breaking the loop and
// leaving the viewport at the wrong position (observed as
// pinned-message navigation stopping mid-scroll in ascending
// sort when the target item is not yet rendered).
// scrollState is typed private but is a plain public field at runtime.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((instance as any).scrollState?.index != null) return;
if (scrollEl.scrollTop > 0) {
instance.scrollToOffset(scrollEl.scrollTop);
}
Expand Down
Loading