mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
913facde71
commit
fc54d84efb
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user