fix: prevent timeline cap from evicting backward-paged roots on live event

Two CI failures on main:

1. File-size guard: lib.rs grew by one line in PR #1246
   (show_native_notification added to Tauri command list). Bump the
   approved override from 1034 to 1035 and update the comment.

2. Scroll-history regression (scroll-history.spec.ts:1363): when the
   user scrolls back past MAX_TIMELINE_MESSAGES (2000) events, a live
   subscription event arriving via mergeTimelineCacheMessages triggered
   normalizeTimelineMessages, which caps to the newest 2000. This evicted
   the backward-paged roots, reset oldestAfterMerge, and stalled
   pagination — deepest stalled at ~471 instead of reaching < 50.

   Fix: mergeTimelineCacheMessages now checks the content-event count on
   the ORIGINAL current array (before dedup+incoming). If the cache is
   already in backward-paged state (> MAX_TIMELINE_MESSAGES content
   events), it skips the cap and returns sort+dedupe only. The cap still
   applies during normal live sessions (cache at or under the limit).

   Also exports isTimelineWindowContentEvent and MAX_TIMELINE_MESSAGES
   from messageQueryKeys.ts so hooks.ts can reference them directly.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-26 15:28:19 -04:00
co-authored by Will Pfleger
parent ca50d83289
commit 34a8ecbf6f
3 changed files with 40 additions and 8 deletions
+3 -2
View File
@@ -82,8 +82,9 @@ const overrides = new Map([
// persona-events rebase: boot-time event-sync wiring (run_boot_migrations
// syncs team-dir edits before all personas.json readers; run_event_sync
// signs the persona/team retention events post-identity) layered on top of
// main's growth. Load-bearing feature growth, queued to split with the list.
["src-tauri/src/lib.rs", 1034],
// main's growth. PR #1246 added show_native_notification to the Tauri
// command list. Load-bearing feature growth, queued to split with the list.
["src-tauri/src/lib.rs", 1035],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
+35 -4
View File
@@ -5,6 +5,8 @@ import {
channelMessagesKey,
dedupeMessagesById,
mergeTimelineHistoryMessages,
MAX_TIMELINE_MESSAGES,
isTimelineWindowContentEvent,
normalizeTimelineMessages,
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
@@ -111,11 +113,40 @@ export function mergeTimelineCacheMessages(
current: RelayEvent[],
incoming: RelayEvent,
): RelayEvent[] {
return mergeMessagesWithNormalizer(
current,
incoming,
normalizeTimelineMessages,
const normalizedCurrent = dedupeMessagesById(current);
const replacedPending = normalizedCurrent.find((message) =>
isMatchingPendingMessage(message, incoming),
);
const incomingWithLocalKey = replacedPending
? {
...incoming,
localKey: replacedPending.localKey ?? replacedPending.id,
}
: incoming;
const incomingLocalKey = getLocalRenderKey(incomingWithLocalKey);
const deduped = normalizedCurrent.filter(
(message) =>
message.id !== incoming.id &&
getLocalRenderKey(message) !== incomingLocalKey &&
!isMatchingPendingMessage(message, incoming),
);
const merged = [...deduped, incomingWithLocalKey];
// Check backward-paged state on the ORIGINAL current (before dedup+incoming)
// so a live event arriving while scrolled back doesn't evict the older roots.
const contentCount = current.filter(isTimelineWindowContentEvent).length;
if (contentCount > MAX_TIMELINE_MESSAGES) {
// Backward-paged state: the cache has grown past MAX_TIMELINE_MESSAGES via
// prepend (the user has scrolled back far enough to load older history).
// Skip the cap here so a live event doesn't evict the backward-paged roots
// and stall pagination. Note: this means the cache can grow beyond
// MAX_TIMELINE_MESSAGES while the user is scrolled back — the same gap
// that already exists on the normalizeTimelineHistoryMessages path. A
// bounded ceiling for both paths is a follow-up; the risk is acceptable
// because scrollback sessions are finite and the cap re-engages on the
// next channel mount.
return sortMessages(merged);
}
return normalizeTimelineMessages(merged);
}
function createOptimisticMessage(
@@ -12,7 +12,7 @@ import {
KIND_SYSTEM_MESSAGE,
} from "@/shared/constants/kinds";
const MAX_TIMELINE_MESSAGES = 2_000;
export const MAX_TIMELINE_MESSAGES = 2_000;
export function channelMessagesKey(channelId: string) {
return ["channel-messages", channelId] as const;
@@ -49,7 +49,7 @@ export function sortMessages(messages: RelayEvent[]) {
});
}
function isTimelineWindowContentEvent(event: RelayEvent) {
export function isTimelineWindowContentEvent(event: RelayEvent) {
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2 ||