mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
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:
parent
022edf3fc4
commit
1cdd562619
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user