Wren's #1486 review: the island-reply summary test exercised main-timeline
aggregation, not buildThreadPanelData. Pin the exact panel shape — a marked
island head with a marked descendant renders head + reply in the thread
panel while producing zero main-timeline entries.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Out-of-band merges (thread ancestors fetched by id, thread-panel reply
subtrees) splice old events into the timeline cache marked nonContiguous.
The pager already ignores them when anchoring its cursor, but the render
path did not: an island painted the start of an old day immediately,
while the middle and end of that day only arrived when contiguous paging
caught up — so they visibly popped in above the reader.
Hide marked events from the main timeline until paging heals them:
- TimelineMessage carries the nonContiguous mark through
formatTimelineMessages,
- buildMainTimelineEntries skips marked entries (the single seam both
ChannelPane and TimelineMessageList render through); island replies
still count toward their visible parent's thread summary,
- countTopLevelTimelineRows mirrors the filter so the pager's row floor
counts only rows that actually render,
- the channel unread marker skips hidden islands so the pill and New
divider never anchor on an invisible row.
Healing is unchanged: when contiguous paging reaches an island the
unmarked copy wins the merge and the whole day appears in one commit,
in order. Thread panels are unaffected — they derive from the full
cache, not the main-timeline entries.
Reported by Tyler while hammering #1483: scrolling up sometimes loaded
the beginning of a day before its end, then the rest popped in.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
maxEventIdAtSecond seeded the bridge composite cursor from every cached
event at the boundary second, but the bridge keyset pages timeline-
content kinds only. An unmarked reaction/edit/deletion at that second
with an id later than the held content prefix would seed before_id past
unseen content rows — a same-second hole. Apply the same
isTimelineWindowContentEvent predicate the frontier helper uses, and pin
it with a regression (aux and island ids at the boundary second are both
ignored; all-aux yields no fabricated cursor).
Found in review by Wren.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
An ancestor/thread fetch can start while an event is missing, a
contiguous history page can fetch that same id unmarked, and the late
out-of-band response then merges for an id that is now contiguous.
mergeNonContiguousTimelineMessages must treat that as a no-op (it
filters incoming ids already in cache before marking), or the frontier
that contiguous paging just advanced gets re-poisoned. Pin that
invariant with a test.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The older-history pager anchored its until cursor on baseline[0] — the
oldest event in the channel cache — assuming the cache is contiguous.
It is not: useLoadMissingAncestors injects thread roots fetched by id,
and useThreadReplies injects whole reply subtrees, both of which can be
days older than the contiguously loaded window. With such an island in
cache, the next scroll-up fetch pages backward from the island and the
history between the island and the real frontier is never requested.
Every later pass anchors on the even-older result, so the hole never
heals — the June 14 → June 9 day-skip, with June 12 sometimes partially
present (the injected roots themselves).
Fix: out-of-band merges mark what they splice in (RelayEvent.nonContiguous,
local-only), and the pager anchors on the oldest UNmarked content event
(oldestContiguousHistoryTimestamp). The state rides on the event, so it
is atomic with the cache write, survives trims, and heals itself: when
contiguous paging reaches an island, the history merge's last-copy-wins
dedupe replaces the flagged copy with the fresh unflagged one. The
dense-second keyset seed skips flagged events for the same reason — an
island at the boundary second is a point, not a fetched prefix.
Regression tests drive the real pager (runPageOlderPass) against a relay
double: red on the previous anchor (island skips the gap days), green on
the frontier anchor, plus island healing and the all-islands fallback.
Also verified end-to-end under node against the full 2,980-event
#buzz-bugs dataset: previously June 11 was missing entirely and June
12/13 reduced to injected fragments; with the fix every day from the
frontier renders and zero relay events in the fetched span are absent
from the cache.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
A channel timeline cache holding more than MAX_TIMELINE_MESSAGES (2000)
content events — i.e. the user paged back to an old day in a busy
channel — was silently trimmed back to the newest 2000 by the next
capped merge: any live event, optimistic send, or fresh-history
revalidation. The rows being read vanished out from under the reader,
appearing as randomly missing messages for old dates (all present right
after scrollback, partially or fully gone seconds later).
Scrollback merges were already uncapped (isOlderHistoryPage special
case), which is what made the loss intermittent instead of consistent.
Fix: no merge into a potentially-on-screen timeline applies the cap.
- mergeTimelineHistoryMessages: sort + dedupe only (drops the
isOlderHistoryPage split — both branches now behave identically).
- mergeTimelineCacheMessages (live/optimistic/single-event): same.
- The DOM bound is enforced where nothing is rendered from the cache:
the cold-snapshot paint (already capped) and a new trim-on-leave in
useChannelSubscription cleanup, which re-applies
normalizeTimelineMessages when the user leaves the channel.
Regression test: expanded 2537-event cache + fresh newest-page
revalidation merge — all old rows survive.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>