fix(desktop): stop competing scroll writers collapsing the load-older anchor

Loading older messages under virtualization let three writers fight over
scrollTop on overlapping frames, so the anchored row jittered or collapsed
to the top (~33% of prepends) and the library's reconcile spun the full 5s
MAX_RECONCILE_MS valve. Establish a single owner of scroll position across
the whole fetch+restore window:

- useLoadOlderOnScroll restores by scrollTop ONLY (drop scrollToIndex), via
  one getOffsetForIndex(anchorIndex + prepended, "start")[0] + intra-row gap
  write. getOffsetForIndex is a pure measurement-cache read, so no library
  scrollState is set and the reconcile loop has nothing to fight.
- The viewport ResizeObserver in useTimelineScrollManager no longer runs a
  competing restore during a fetch: it skips while isFetchingOlder is true
  (the spinner's clientHeight 720->590 mount-shift fires before the lock is
  set) and otherwise defers to lockedScrollTopRef when the load-older restore
  holds it. MessageTimeline threads isFetchingOlder into the manager.

The defect was invisible to unit tests (jsdom getBoundingClientRect -> 0) and
to static traces; the new load-older E2E drives a real prepend on six fresh
page loads and asserts the anchor holds every run, the scroller genuinely
grew, and the reconcile terminates. emitMockHistory now honors the relay
filter's until/limit so the mock relay paginates like a real one, which the
E2E needs to exercise a genuine older page.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-18 18:30:42 -04:00
co-authored by Will Pfleger
parent 913facde71
commit fc54d84efb
3 changed files with 192 additions and 7 deletions
@@ -98,12 +98,17 @@ export function useLoadOlderOnScroll({
(virtualizerRef.current?.itemCount ?? previousCount) -
previousCount;
if (after && anchorIndex !== null && prepended > 0) {
after.scrollToIndex(anchorIndex + prepended, {
align: "start",
});
// scrollToIndex aligns the row's top to the viewport top;
// re-apply the captured gap so the view doesn't nudge by a
// partial row.
// Restore by scrollTop ONLY — a single writer. Compute the
// anchored row's top via getOffsetForIndex (a pure read of
// the measurement cache, no scrollState) and add back the
// captured intra-row gap. Calling scrollToIndex here too
// would set the library's scrollState aiming at the row TOP
// while this restore aims at row top + gap; the two write
// scrollTop to different values on overlapping rAF frames,
// so the library's reconcile never reaches approxEqual,
// never re-scrolls (its target is unchanged), and spins one
// rAF/frame for the full 5s MAX_RECONCILE_MS valve on every
// prepend. One mechanism, no fight.
const target = after.getOffsetForIndex(
anchorIndex + prepended,
"start",
+49 -1
View File
@@ -1509,6 +1509,37 @@ const mockChannels: MockChannel[] = [
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700),
],
}),
// Deep history channel for the load-older-under-virtualization E2E. Seeded
// with more messages than CHANNEL_HISTORY_LIMIT (200) so the initial load
// windows to the newest page and a `fetchOlder` (until-cursor) prepend has
// genuinely older rows to add — exercising the scroll-restore anchor under
// virtualization. Its own channel so existing channels' row-index and unread
// assertions stay undisturbed.
createMockChannel({
id: "feedf00d-0000-4000-8000-000000000007",
name: "deep-history",
channel_type: "stream",
visibility: "open",
description: "Channel with paginated history for load-older tests",
topic: null,
purpose: null,
last_message_at: isoMinutesAgo(1),
archived_at: null,
created_by: ALICE_PUBKEY,
topic_set_by: null,
topic_set_at: null,
purpose_set_by: null,
purpose_set_at: null,
topic_required: false,
max_members: null,
nip29_group_id: null,
created_minutes_ago: 2000,
updated_minutes_ago: 1,
members: [
createMockMember(ALICE_PUBKEY, "owner", 2000),
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900),
],
}),
];
const mockMessages = new Map<string, RelayEvent[]>();
@@ -2275,7 +2306,24 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
sig: "mocksig".repeat(20).slice(0, 128),
},
]
: [];
: channelId === "feedf00d-0000-4000-8000-000000000007"
? // 600 messages > CHANNEL_HISTORY_LIMIT (200): the initial load
// windows to the newest 200, leaving 400 older behind the until
// cursor — enough for several full fetchOlder pages (batch 100),
// so the load-older anchor restore is exercised across REPEATED
// prepend cycles, not a single lucky pass. created_at increases
// with index (oldest first) so message N+1 is newer than N — the
// anchor restores the first-visible row across each prepend.
Array.from({ length: 600 }, (_, index) => ({
id: `mock-deep-history-${index}`,
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - (600 - index) * 60,
kind: 9,
tags: [["h", channelId]],
content: `Deep history message #${index}`,
sig: "mocksig".repeat(20).slice(0, 128),
}))
: [];
mockMessages.set(channelId, seeded);
return seeded;