From 1cdd56261956cc1a34201a95e48c1ba82f14e70d Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Fri, 19 Jun 2026 20:26:59 -0400 Subject: [PATCH] fix(desktop): keep oldest window on scrollback so the channel head renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Tyler Longwell --- .../messages/lib/messageQueryKeys.test.mjs | 78 +++++++++++++++++++ .../features/messages/lib/messageQueryKeys.ts | 37 +++++++-- .../messages/useFetchOlderMessages.ts | 5 +- 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs index 5c1eaa0773..d707af3482 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs +++ b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/messages/lib/messageQueryKeys.ts b/desktop/src/features/messages/lib/messageQueryKeys.ts index 454a6873cb..b854736466 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.ts +++ b/desktop/src/features/messages/lib/messageQueryKeys.ts @@ -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); } diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index 6cda1556b7..c736a098b8 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -88,7 +88,10 @@ export function useFetchOlderMessages(channel: Channel | null) { if (olderMessages.length > 0) { queryClient.setQueryData(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`