fix(desktop): keep oldest window on scrollback so the channel head renders

The timeline caps visible content at MAX_TIMELINE_MESSAGES (2000) and always
kept the *newest* window, evicting the oldest. That's correct for live-tailing
but backwards for scrollback: paging back to the start of a channel with more
than 2000 content events evicted the head — the first messages never rendered.
On a non-virtualized list the cap is the only DOM bound, so it can't simply be
lifted.

Make eviction direction-aware. normalizeTimelineMessages / mergeTimelineHistory-
Messages take a `keep` end ('newest' | 'oldest', default 'newest' so cold-load
and live-tail are unchanged). The scrollback merge in useFetchOlderMessages
passes 'oldest', so the head the reader is scrolling toward survives and the
far newer tail (which they've left and which live/cold-load re-seeds) ages off
instead. Same 2000-row DOM bound either way.

Verified against #buzz-bugs (~2104 content events): scrolling to the start now
renders the channel_created genesis row; previously the first ~104 messages were
clipped.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-19 20:26:59 -04:00
parent 022edf3fc4
commit 1cdd562619
3 changed files with 113 additions and 7 deletions

View File

@@ -218,3 +218,81 @@ test("sortMessages tiebreaks same-second events on id, order-independent", () =>
assert.deepEqual(forward, reverse);
assert.deepEqual(forward, [a.id, b.id, c.id]);
});
test("scrollback merge keeps the oldest window so the head survives past the cap", () => {
// A channel with more than MAX_TIMELINE_MESSAGES (2000) content events: the
// reader has paged all the way back to the start. The default "newest"
// eviction would clip the head (the first messages never render) — the bug
// Tyler hit at the very beginning of #buzz-bugs. Scrollback passes "oldest".
const current = [];
for (let index = 0; index < 1_900; index += 1) {
current.push(event({ id: id("mid", index), createdAt: 5_000 + index }));
}
// The page that finally reaches the channel start: 200 oldest events,
// including the very first (genesis) row.
const olderPage = [];
for (let index = 0; index < 200; index += 1) {
olderPage.push(event({ id: id("head", index), createdAt: 1_000 + index }));
}
const genesisId = id("head", 0);
const newestKept = mergeTimelineHistoryMessages(current, olderPage);
const oldestKept = mergeTimelineHistoryMessages(current, olderPage, "oldest");
// Both cap visible content at 2000.
assert.equal(newestKept.filter((m) => m.kind === 9).length, 2_000);
assert.equal(oldestKept.filter((m) => m.kind === 9).length, 2_000);
// Default "newest" evicts the head — the channel's first messages vanish.
assert.equal(
newestKept.some((m) => m.id === genesisId),
false,
);
// Scrollback "oldest" preserves the head the reader scrolled to.
assert.equal(
oldestKept.some((m) => m.id === genesisId),
true,
);
assert.equal(oldestKept[0].id, genesisId);
// The trade-off: the newest tail rows are the ones that age off instead.
assert.equal(
oldestKept.some((m) => m.id === id("mid", 1_899)),
false,
);
});
test("normalizeTimelineMessages keep='oldest' retains non-content events", () => {
// Aux events (reactions/deletions) tied to retained roots must survive even
// when the visible window keeps the oldest end.
const messages = [];
for (let index = 0; index < 2_100; index += 1) {
messages.push(event({ id: id("msg", index), createdAt: 1_000 + index }));
}
const oldestRoot = id("msg", 0);
const reaction = `${"f".repeat(63)}1`;
messages.push(
event({
id: reaction,
kind: 7,
createdAt: 9_000,
tags: [
["h", CHANNEL_ID],
["e", oldestRoot],
],
content: "+",
}),
);
const normalized = normalizeTimelineMessages(messages, "oldest");
assert.equal(normalized.filter((m) => m.kind === 9).length, 2_000);
assert.equal(
normalized.some((m) => m.id === oldestRoot),
true,
);
// Reaction is retained regardless of which content window survives.
assert.equal(
normalized.some((m) => m.id === reaction),
true,
);
});

View File

@@ -64,16 +64,38 @@ function isTimelineWindowContentEvent(event: RelayEvent) {
);
}
/**
* Which end of the timeline to keep when the visible-content window overflows
* {@link MAX_TIMELINE_MESSAGES}.
*
* - `"newest"` (default): keep the most recent rows, evict the oldest. Correct
* for cold-load and live-tailing — new messages arrive at the bottom, so the
* far-older head can age off without the reader noticing.
* - `"oldest"`: keep the earliest rows, evict the newest. Correct for
* scrollback — the reader is moving *toward* the head, so evicting the head
* would delete the very rows they're scrolling to (e.g. the first messages of
* the channel never render once history exceeds the cap).
*/
type TimelineWindowEnd = "newest" | "oldest";
/**
* Sort, dedupe, and cap the timeline at {@link MAX_TIMELINE_MESSAGES} visible
* content events so de-virtualized rendering does not grow into an unbounded
* DOM during long-lived channel sessions.
*
* `keep` selects which end survives when over the cap — see
* {@link TimelineWindowEnd}. Defaults to `"newest"` so live-tail and cold-load
* behaviour is unchanged; scrollback merges pass `"oldest"` so paging back to
* the start of a >cap channel does not evict the head out from under the reader.
*
* Auxiliary events (reactions, edits, tombstones) are kept in cache so they can
* still apply to retained or later-loaded content, but they must not consume the
* visible message window and evict older loaded roots.
* visible message window and evict loaded roots.
*/
export function normalizeTimelineMessages(messages: RelayEvent[]) {
export function normalizeTimelineMessages(
messages: RelayEvent[],
keep: TimelineWindowEnd = "newest",
) {
const normalized = sortMessages(messages);
const contentEvents = normalized.filter(isTimelineWindowContentEvent);
@@ -81,9 +103,11 @@ export function normalizeTimelineMessages(messages: RelayEvent[]) {
return normalized;
}
const retainedContentIds = new Set(
contentEvents.slice(-MAX_TIMELINE_MESSAGES).map((event) => event.id),
);
const retainedWindow =
keep === "oldest"
? contentEvents.slice(0, MAX_TIMELINE_MESSAGES)
: contentEvents.slice(-MAX_TIMELINE_MESSAGES);
const retainedContentIds = new Set(retainedWindow.map((event) => event.id));
return normalized.filter(
(event) =>
@@ -94,6 +118,7 @@ export function normalizeTimelineMessages(messages: RelayEvent[]) {
export function mergeTimelineHistoryMessages(
current: RelayEvent[],
history: RelayEvent[],
keep: TimelineWindowEnd = "newest",
) {
return normalizeTimelineMessages([...current, ...history]);
return normalizeTimelineMessages([...current, ...history], keep);
}

View File

@@ -88,7 +88,10 @@ export function useFetchOlderMessages(channel: Channel | null) {
if (olderMessages.length > 0) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
mergeTimelineHistoryMessages(current, olderMessages),
// Scrollback grows the head: keep the *oldest* window so paging
// back to the start of a >cap channel doesn't evict the first
// messages out from under the reader (see normalizeTimelineMessages).
mergeTimelineHistoryMessages(current, olderMessages, "oldest"),
);
// Backfill the older messages' reactions/edits/deletions by `#e`