From 8dba0b50bf6a1134f87e068ca73e5782e6b1d2cb Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 19:35:36 -0400 Subject: [PATCH 01/53] Virtualize channel timeline with Virtua Replace the preview timeline path with Virtua-owned windowing, prepend anchoring, and bottom state. Remove blocking wheel handling and per-scroll anchor walks from the experiment while preserving indexed navigation and thread summaries. Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/package.json | 1 + desktop/src/app/AppShell.tsx | 3 +- .../features/messages/ui/MessageTimeline.tsx | 489 +++++++++++------- .../messages/ui/TimelineMessageList.tsx | 252 ++++++++- .../features/messages/ui/useAnchoredScroll.ts | 161 +++++- .../hooks/useWebviewScrollBoundaryLock.ts | 6 +- pnpm-lock.yaml | 28 + preview-features.json | 8 + 8 files changed, 727 insertions(+), 221 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index ed17b84e33..e2bb06d475 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -77,6 +77,7 @@ "tailwind-merge": "^3.5.0", "tiptap-markdown": "^0.9.0", "upng-js": "^2.1.0", + "virtua": "0.49.2", "yaml": "^2.8.3", "zod": "^4.4.3" }, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 53d81ecae0..ce6151c62a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,7 +96,8 @@ const LazySettingsScreen = React.lazy(async () => { export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); - useWebviewScrollBoundaryLock(); + const virtuaTimelineEnabled = useFeatureEnabled("virtuaTimeline"); + useWebviewScrollBoundaryLock(!virtuaTimelineEnabled); const workspacesHook = useWorkspaces(); const workspaceRailEnabled = useFeatureEnabled("workspaceRail"); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index e007e3f915..775c5d6fd2 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -13,6 +13,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { Spinner } from "@/shared/ui/spinner"; @@ -21,6 +22,7 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; +import type { TimelineVirtualizerApi } from "./TimelineMessageList"; import { useAnchoredScroll } from "./useAnchoredScroll"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; @@ -191,6 +193,21 @@ const MessageTimelineBase = React.forwardRef< const scrollContainerRef = externalScrollRef ?? internalScrollRef; const contentRef = React.useRef(null); const topSentinelRef = React.useRef(null); + const [virtualizerScrollParent, setVirtualizerScrollParent] = + React.useState(null); + const [virtualizerRenderVersion, bumpVirtualizerRenderVersion] = + React.useReducer((version: number) => version + 1, 0); + const [timelineVirtualizerApi, setTimelineVirtualizerApi] = + React.useState(null); + const useTimelineVirtualizer = useFeatureEnabled("virtuaTimeline"); + const activeScrollContainerRef = React.useMemo( + () => ({ + get current() { + return virtualizerScrollParent ?? scrollContainerRef.current; + }, + }), + [scrollContainerRef, virtualizerScrollParent], + ); // Gate the heavy timeline render (each row runs a synchronous // react-markdown parse) behind React concurrency. `useDeferredValue` lets the @@ -230,6 +247,16 @@ const MessageTimelineBase = React.forwardRef< // painted at a stale offset until the user's next scroll event forces layout. const scrollContainerDomKey = channelId ?? "none"; + // biome-ignore lint/correctness/useExhaustiveDependencies: this effect is scoped to scroll DOM swaps; when the Experiments flag hydrates, the virtualizer child registers its own scroller/API via callbacks, and re-running this reset on the flag transition can briefly clear that API during navigation tests. + React.useLayoutEffect(() => { + // Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node. + void scrollContainerDomKey; + if (!useTimelineVirtualizer) { + setVirtualizerScrollParent(scrollContainerRef.current); + } + setTimelineVirtualizerApi(null); + }, [scrollContainerRef, scrollContainerDomKey]); + const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, isLoading: isLoading || isDeferredSnapshotStale, @@ -245,14 +272,19 @@ const MessageTimelineBase = React.forwardRef< scrollToBottom, scrollToBottomOnNextUpdate, scrollToMessage, + onVirtualizerAtBottomStateChange, } = useAnchoredScroll({ channelId, contentRef, isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, - scrollContainerRef, + scrollContainerRef: activeScrollContainerRef, targetMessageId, + virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, + virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom, + virtualizerOwnsPrependAnchoring: useTimelineVirtualizer, + virtualizerRenderVersion, }); const timelineIntroSurface = selectTimelineIntroSurface({ @@ -266,11 +298,18 @@ const MessageTimelineBase = React.forwardRef< const showDirectMessageIntro = timelineIntroSurface === "direct-message-intro"; const showChannelIntro = timelineIntroSurface === "channel-intro"; - const activeDirectMessageIntro = showDirectMessageIntro - ? directMessageIntro - : null; - const activeChannelIntro = showChannelIntro ? channelIntro : null; - const showIntro = showDirectMessageIntro || showChannelIntro; + const activeDirectMessageIntro = + showDirectMessageIntro && + (!useTimelineVirtualizer || timelineBodySurface !== "list") + ? directMessageIntro + : null; + const activeChannelIntro = + showChannelIntro && + (!useTimelineVirtualizer || timelineBodySurface !== "list") + ? channelIntro + : null; + const showIntro = + activeDirectMessageIntro !== null || activeChannelIntro !== null; const showGenericEmpty = timelineBodySurface === "empty" && !showIntro; const showMessageList = timelineBodySurface === "list"; @@ -349,20 +388,57 @@ const MessageTimelineBase = React.forwardRef< } }, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]); - // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits. + // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it. React.useEffect(() => { const target = pendingSearchTargetRef.current; if (!target || showTimelineSkeleton) return; + if ( + useTimelineVirtualizer && + !activeScrollContainerRef.current?.querySelector( + `[data-message-id="${CSS.escape(target)}"]`, + ) + ) { + // Phase 1: ask the virtualizer to realize the match's index. The retry effect + // runs again on range change and the DOM-visible path does the actual + // center + highlight once the row exists. + void jumpToMessage(target, { behavior: "auto" }); + return; + } if (jumpToMessage(target, { behavior: "auto" })) { pendingSearchTargetRef.current = null; } - }, [deferredMessages, jumpToMessage, showTimelineSkeleton]); + }, [ + deferredMessages, + jumpToMessage, + showTimelineSkeleton, + virtualizerRenderVersion, + ]); + + const loadOlderViaVirtualizer = React.useCallback(() => { + // Indexed find navigation can legitimately land near the current history + // boundary. Do not mistake that programmatic jump for scrollback intent and + // prepend underneath the active match. + if ( + searchActiveMessageId || + !fetchOlder || + showTimelineSkeleton || + !hasOlderMessages + ) { + return; + } + void fetchOlder(); + }, [ + fetchOlder, + hasOlderMessages, + searchActiveMessageId, + showTimelineSkeleton, + ]); useLoadOlderOnScroll({ - fetchOlder, + fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder, hasOlderMessages, isLoading: showTimelineSkeleton, - scrollContainerRef, + scrollContainerRef: activeScrollContainerRef, sentinelRef: topSentinelRef, }); @@ -372,6 +448,53 @@ const MessageTimelineBase = React.forwardRef< messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages, }); + const handleVirtualizerRangeChanged = React.useCallback(() => { + bumpVirtualizerRenderVersion(); + }, []); + + const timelineList = showMessageList ? ( + + ) : null; + return (
@@ -408,217 +531,205 @@ const MessageTimelineBase = React.forwardRef< ) : null}
-
-
- - {/* Fixed-height slot: an always-mounted height keeps the virtual - spacer's offset stable across the load-older fetch toggle, so - `scrollMargin` doesn't shift mid-fetch and yank the restore. The - visible fetch spinner lives in the absolute overlay above, which - does not occupy inline flow. */} -
- + {useTimelineVirtualizer && timelineList ? ( +
+ {timelineList} +
+ ) : (
- {showTimelineSkeleton ? ( - - ) : null} - {activeDirectMessageIntro ? ( -
- -

- {activeDirectMessageIntro.displayName} -

-

- This is the beginning of your direct message with{" "} - - {activeDirectMessageIntro.displayName} - - . -

-
- ) : null} - - {activeChannelIntro ? ( -
+
+ + {/* Fixed-height slot: an always-mounted height keeps the virtual + spacer's offset stable across the load-older fetch toggle, so + `scrollMargin` doesn't shift mid-fetch and yank the restore. The + visible fetch spinner lives in the absolute overlay above, which + does not occupy inline flow. */} +
+ +
+ {showTimelineSkeleton ? ( + + ) : null} + {activeDirectMessageIntro ? (
- {activeChannelIntro.icon ?? ( - - )} + +

+ {activeDirectMessageIntro.displayName} +

+

+ This is the beginning of your direct message with{" "} + + {activeDirectMessageIntro.displayName} + + . +

-

- #{activeChannelIntro.channelName} -

-

- This is the beginning of the{" "} - - {activeChannelIntro.channelKindLabel} - - . -

- {activeChannelIntro.description ? ( -

- {activeChannelIntro.description} + ) : null} + + {activeChannelIntro ? ( +

+
+ {activeChannelIntro.icon ?? ( + + )} +
+

+ #{activeChannelIntro.channelName} +

+

+ This is the beginning of the{" "} + + {activeChannelIntro.channelKindLabel} + + .

- ) : null} - {activeChannelIntro.actions?.length ? ( -
- {activeChannelIntro.actions.map((action) => { - const hasDescription = Boolean(action.description); - - return ( - - ); - })} -
- ) : null} -
- ) : null} - - {showGenericEmpty ? ( -
-

- {emptyTitle} -

-

- {emptyDescription} -

-
- ) : null} - - {showMessageList ? ( -
- -
- ) : null} + {action.description ? ( + + {action.description} + + ) : null} + + + ); + })} +
+ ) : null} +
+ ) : null} + + {showGenericEmpty ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null} + + {showMessageList ? ( +
+ {timelineList} +
+ ) : null} +
-
+ )}
{!isAtBottom ? ( diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index fdc9679104..f94785b9ac 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +import { VList } from "virtua"; +import type { VListHandle } from "virtua"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; @@ -6,6 +8,7 @@ import { buildTimelineDayGroups, buildTimelineItems, getTimelineItemKey, + type TimelineDayGroup, type TimelineNonDayItem, } from "@/features/messages/lib/timelineItems"; import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; @@ -28,6 +31,14 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; import { UnreadDivider } from "./UnreadDivider"; +export type TimelineVirtualizerApi = { + scrollToBottom: (behavior?: ScrollBehavior) => void; + scrollToMessage: ( + messageId: string, + options?: { behavior?: ScrollBehavior }, + ) => boolean; +}; + type TimelineMessageListProps = { agentPubkeys?: ReadonlySet; channelId?: string | null; @@ -81,6 +92,13 @@ type TimelineMessageListProps = { searchQuery?: string; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + /** The virtualized timeline owns its scroll node when enabled. */ + useVirtualizer?: boolean; + onStartReached?: () => void; + onAtBottomStateChange?: (atBottom: boolean) => void; + onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; + onVirtualizerRangeChanged?: () => void; + onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -114,6 +132,12 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, + useVirtualizer = false, + onStartReached, + onAtBottomStateChange, + onVirtualizerApiChange, + onVirtualizerRangeChanged, + onVirtualizerScrollerChange, }: TimelineMessageListProps) { const entries = React.useMemo( () => @@ -255,6 +279,20 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ], ); + if (useVirtualizer) { + return ( + + ); + } + return (
{dayGroups.map((group) => ( @@ -276,13 +314,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ )} {group.items.map((item) => ( -
+ {renderItem(item)} -
+ ))} ))} @@ -290,6 +324,212 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); }); +type VirtualizedTimelineItem = + | { kind: "day-heading"; group: TimelineDayGroup } + | { kind: "timeline-item"; item: TimelineNonDayItem }; + +function timelineItemMessageId(item: TimelineNonDayItem): string | null { + return item.kind === "message" || item.kind === "system" + ? item.entry.message.id + : null; +} + +function virtualizedItemKey(item: VirtualizedTimelineItem): string { + return item.kind === "day-heading" + ? `day-${item.group.key}` + : getTimelineItemKey(item.item); +} + +function buildVirtualizedItems( + dayGroups: readonly TimelineDayGroup[], +): VirtualizedTimelineItem[] { + return dayGroups.flatMap((group) => [ + { kind: "day-heading" as const, group }, + ...group.items.map((item) => ({ kind: "timeline-item" as const, item })), + ]); +} + +type VirtualizedTimelineRowsProps = { + dayGroups: TimelineDayGroup[]; + onAtBottomStateChange?: (atBottom: boolean) => void; + onStartReached?: () => void; + onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; + onVirtualizerRangeChanged?: () => void; + onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; + renderItem: (item: TimelineNonDayItem) => React.ReactNode; +}; + +function VirtualizedTimelineRows({ + dayGroups, + onAtBottomStateChange, + onStartReached, + onVirtualizerApiChange, + onVirtualizerRangeChanged, + onVirtualizerScrollerChange, + renderItem, +}: VirtualizedTimelineRowsProps) { + const listRef = React.useRef(null); + const hostRef = React.useRef(null); + const hasInitialPositionedRef = React.useRef(false); + const items = React.useMemo( + () => buildVirtualizedItems(dayGroups), + [dayGroups], + ); + const previousFirstTimelineKeyRef = React.useRef(null); + const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); + const firstTimelineKey = React.useMemo(() => { + const first = items.find((item) => item.kind === "timeline-item"); + return first?.kind === "timeline-item" + ? getTimelineItemKey(first.item) + : null; + }, [items]); + const isPrepend = React.useMemo(() => { + const previousFirstKey = previousFirstTimelineKeyRef.current; + return previousFirstKey !== null && keys.indexOf(previousFirstKey) > 0; + }, [keys]); + React.useLayoutEffect(() => { + previousFirstTimelineKeyRef.current = firstTimelineKey; + if (!hasInitialPositionedRef.current && items.length > 0) { + hasInitialPositionedRef.current = true; + listRef.current?.scrollToIndex(items.length - 1, { align: "end" }); + } + }, [firstTimelineKey, items.length]); + + const messageItemIndexById = React.useMemo(() => { + const byId = new Map(); + items.forEach((item, index) => { + if (item.kind !== "timeline-item") return; + const messageId = timelineItemMessageId(item.item); + if (messageId) byId.set(messageId, index); + }); + return byId; + }, [items]); + const persistentIndexes = React.useMemo( + () => + items.flatMap((item, index) => + item.kind === "timeline-item" && + item.item.kind === "message" && + item.item.entry.summary + ? [index] + : [], + ), + [items], + ); + + React.useLayoutEffect(() => { + const scroller = hostRef.current?.firstElementChild; + const element = scroller instanceof HTMLDivElement ? scroller : null; + if (element) { + element.dataset.buzzConversationScroll = "true"; + element.dataset.testid = "message-timeline"; + } + onVirtualizerScrollerChange?.(element); + return () => onVirtualizerScrollerChange?.(null); + }, [onVirtualizerScrollerChange]); + + React.useLayoutEffect(() => { + if (!onVirtualizerApiChange) return; + const api: TimelineVirtualizerApi = { + scrollToBottom() { + if (items.length > 0) { + listRef.current?.scrollToIndex(items.length - 1, { align: "end" }); + } + }, + scrollToMessage(messageId) { + const index = messageItemIndexById.get(messageId); + if (index === undefined) return false; + listRef.current?.scrollToIndex(index, { align: "center" }); + return true; + }, + }; + onVirtualizerApiChange(api); + return () => onVirtualizerApiChange(null); + }, [items.length, messageItemIndexById, onVirtualizerApiChange]); + + const handleScroll = React.useCallback( + (offset: number) => { + const list = listRef.current; + if (!list) return; + onVirtualizerRangeChanged?.(); + onAtBottomStateChange?.( + list.scrollSize - list.viewportSize - offset <= 32, + ); + if (offset <= 200) onStartReached?.(); + }, + [onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged], + ); + + return ( +
+ + {(item) => { + if (item.kind === "day-heading") { + const { group } = item; + return ( +
+ {group.headingTimestamp === null ? null : ( + + )} +
+ ); + } + return ( + + {renderItem(item.item)} + + ); + }} +
+
+ ); +} + +function TimelineRowShell({ + children, + item, + useContentVisibility = true, +}: { + children: React.ReactNode; + item: TimelineNonDayItem; + useContentVisibility?: boolean; +}) { + return ( +
+ {children} +
+ ); +} + function SystemRow({ currentPubkey, entry, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2c0176df7f..2abe471c0f 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -49,6 +49,16 @@ type UseAnchoredScrollOptions = { /** When set, scroll to and highlight this message on mount and on change. */ targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; + virtualScrollToMessage?: ( + messageId: string, + options?: { behavior?: ScrollBehavior }, + ) => boolean; + /** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */ + virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; + /** When active, the virtualizer owns prepend compensation and bottom-state synchronization. */ + virtualizerOwnsPrependAnchoring?: boolean; + /** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */ + virtualizerRenderVersion?: number; }; type UseAnchoredScrollResult = { @@ -72,6 +82,8 @@ type UseAnchoredScrollResult = { messageId: string, options?: { highlight?: boolean; behavior?: ScrollBehavior }, ) => boolean; + /** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */ + onVirtualizerAtBottomStateChange: (atBottom: boolean) => void; }; function isAtBottomNow( @@ -150,6 +162,10 @@ export function useAnchoredScroll({ targetMessageId = null, onTargetReached, + virtualScrollToMessage, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring = false, + virtualizerRenderVersion = 0, }: UseAnchoredScrollOptions): UseAnchoredScrollResult { // Anchor lives in a ref because it must survive renders and is updated // both on scroll (commit-time read) and in the layout effect (post-render @@ -222,11 +238,19 @@ export function useAnchoredScroll({ // every imperative bottom jump so `onScroll` holds the at-bottom anchor // until it can snap to the true floor. settlingRef.current = true; - container.scrollTo({ top: container.scrollHeight, behavior }); + if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { + virtualScrollToBottom(behavior); + } else { + container.scrollTo({ top: container.scrollHeight, behavior }); + } setIsAtBottom(true); setNewMessageCount(0); }, - [scrollContainerRef], + [ + scrollContainerRef, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring, + ], ); // Arm a one-shot: the next append snaps to bottom regardless of where the @@ -236,6 +260,19 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = true; }, []); + const highlightMessage = React.useCallback((messageId: string) => { + if (highlightTimeoutRef.current !== null) { + window.clearTimeout(highlightTimeoutRef.current); + } + setHighlightedMessageId(messageId); + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + highlightTimeoutRef.current = null; + }, 2_000); + }, []); + const scrollToMessageImperative = React.useCallback( ( messageId: string, @@ -246,6 +283,44 @@ export function useAnchoredScroll({ const el = container.querySelector( `[data-message-id="${messageId}"]`, ); + if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) { + if (el) { + const rect = el.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const isInViewport = + rect.top >= containerRect.top && + rect.bottom <= containerRect.bottom; + if (!isInViewport) { + if (!virtualScrollToMessage(messageId, { behavior: "auto" })) { + return false; + } + anchorRef.current = { kind: "message", messageId, topOffset: 0 }; + setIsAtBottom(false); + return false; + } + const centeredTop = (container.clientHeight - rect.height) / 2; + container.scrollTo({ + top: Math.max( + 0, + container.scrollTop + + (rect.top - containerRect.top) - + centeredTop, + ), + behavior: options.behavior ?? "auto", + }); + } else if ( + !virtualScrollToMessage(messageId, { + behavior: options.behavior ?? "auto", + }) + ) { + return false; + } + anchorRef.current = { kind: "message", messageId, topOffset: 0 }; + setIsAtBottom(false); + if (el && options.highlight) highlightMessage(messageId); + return el !== null; + } + if (!el) return false; const rect = el.getBoundingClientRect(); @@ -279,21 +354,15 @@ export function useAnchoredScroll({ }; setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX); - if (options.highlight) { - if (highlightTimeoutRef.current !== null) { - window.clearTimeout(highlightTimeoutRef.current); - } - setHighlightedMessageId(messageId); - highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedMessageId((current) => - current === messageId ? null : current, - ); - highlightTimeoutRef.current = null; - }, 2_000); - } + if (options.highlight) highlightMessage(messageId); return true; }, - [scrollContainerRef], + [ + highlightMessage, + scrollContainerRef, + virtualizerOwnsPrependAnchoring, + virtualScrollToMessage, + ], ); // Scroll handler: recompute anchor + bottom state from the current @@ -302,6 +371,9 @@ export function useAnchoredScroll({ const onScroll = React.useCallback(() => { const container = scrollContainerRef.current; if (!container) return; + // Virtua owns anchoring and reports bottom state separately. Avoid the + // fallback's O(N) DOM walk on every compositor-driven scroll event. + if (virtualizerOwnsPrependAnchoring) return; // Row measurement can grow `scrollHeight` after a bottom pin and emit scroll // events while `scrollTop` holds at the old floor — opening a transient gap // above the true bottom. `computeAnchor` would read that as a deliberate @@ -311,6 +383,9 @@ export function useAnchoredScroll({ if (settleProgrammaticBottomPin(container)) { settlingRef.current = false; } else { + if (virtualizerOwnsPrependAnchoring) { + settlingRef.current = false; + } return; } } @@ -320,7 +395,7 @@ export function useAnchoredScroll({ if (atBottom) { setNewMessageCount(0); } - }, [scrollContainerRef]); + }, [scrollContainerRef, virtualizerOwnsPrependAnchoring]); // --------------------------------------------------------------------------- // Anchor restoration: after every render, stick to the bottom if the user is @@ -396,7 +471,11 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = false; anchorRef.current = { kind: "at-bottom" }; settlingRef.current = true; - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { + virtualScrollToBottom("auto"); + } else { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } setIsAtBottom(true); setNewMessageCount(0); prevLastMessageIdRef.current = lastMessage?.id; @@ -407,10 +486,13 @@ export function useAnchoredScroll({ } if (anchor.kind === "at-bottom") { - // Stick to bottom across the append. - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + // Stick to bottom across the append. In virtualized mode, the + // virtualizer owns this write; do not slam its scroll element directly. + if (!virtualizerOwnsPrependAnchoring) { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } if (newLatestArrived) setNewMessageCount(0); - } else if (messagesArrived > 0) { + } else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) { // Anchored mid-history. An older-history prepend grows the content above // the reading row; the browser's native scroll anchoring does NOT correct // this at the top edge (no anchor node above the viewport when scrollTop @@ -449,6 +531,8 @@ export function useAnchoredScroll({ scrollToBottomImperative, scrollToMessageImperative, targetMessageId, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring, ]); // --------------------------------------------------------------------------- @@ -466,13 +550,21 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if (anchorRef.current.kind === "at-bottom") { + if ( + anchorRef.current.kind === "at-bottom" && + !virtualizerOwnsPrependAnchoring + ) { container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); } }); observer.observe(content); return () => observer.disconnect(); - }, [channelId, contentRef, scrollContainerRef]); + }, [ + channelId, + contentRef, + scrollContainerRef, + virtualizerOwnsPrependAnchoring, + ]); // --------------------------------------------------------------------------- // Target message handling (deep link, jump-to-reply, etc.). Distinct from @@ -486,7 +578,7 @@ export function useAnchoredScroll({ // *without* marking the target handled until its row actually exists — each // subsequent message commit re-runs the effect and retries the centering. // --------------------------------------------------------------------------- - // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits. + // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits. React.useEffect(() => { if (!targetMessageId) { handledTargetIdRef.current = null; @@ -495,11 +587,19 @@ export function useAnchoredScroll({ if (handledTargetIdRef.current === targetMessageId || isLoading) return; if (!hasInitializedRef.current) return; // initial-mount path will handle. + void virtualizerRenderVersion; const container = scrollContainerRef.current; if (!container) return; const el = container.querySelector( `[data-message-id="${targetMessageId}"]`, ); + if (!el && virtualizerOwnsPrependAnchoring) { + if (scrollToMessageImperative(targetMessageId, { highlight: true })) { + handledTargetIdRef.current = targetMessageId; + onTargetReached?.(targetMessageId); + } + return; + } if (!el) { // Row not in the DOM yet. A cold deep-link target is fetched by id and // spliced into `messages` a render or two later; this effect re-runs on @@ -516,6 +616,8 @@ export function useAnchoredScroll({ scrollContainerRef, scrollToMessageImperative, targetMessageId, + virtualizerOwnsPrependAnchoring, + virtualizerRenderVersion, ]); React.useEffect(() => { @@ -526,6 +628,18 @@ export function useAnchoredScroll({ }; }, []); + const onVirtualizerAtBottomStateChange = React.useCallback( + (atBottom: boolean) => { + if (!virtualizerOwnsPrependAnchoring) return; + if (atBottom) { + anchorRef.current = { kind: "at-bottom" }; + setNewMessageCount(0); + } + setIsAtBottom(atBottom); + }, + [virtualizerOwnsPrependAnchoring], + ); + return { onScroll, isAtBottom, @@ -534,5 +648,6 @@ export function useAnchoredScroll({ scrollToBottom: scrollToBottomImperative, scrollToBottomOnNextUpdate, scrollToMessage: scrollToMessageImperative, + onVirtualizerAtBottomStateChange, }; } diff --git a/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts b/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts index 883c1eeeb7..a37b2a7cef 100644 --- a/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts +++ b/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts @@ -80,8 +80,10 @@ function isConversationScroller(element: HTMLElement) { * remain; every other boundary — including all horizontal ones — is locked and * cannot chain to the viewport. */ -export function useWebviewScrollBoundaryLock() { +export function useWebviewScrollBoundaryLock(enabled = true) { React.useEffect(() => { + if (!enabled) return; + function handleWheel(event: WheelEvent) { if (event.defaultPrevented || event.ctrlKey) { return; @@ -137,5 +139,5 @@ export function useWebviewScrollBoundaryLock() { return () => { window.removeEventListener("wheel", handleWheel, { capture: true }); }; - }, []); + }, [enabled]); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b678ba734b..38630258f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: upng-js: specifier: ^2.1.0 version: 2.1.0 + virtua: + specifier: 0.49.2 + version: 0.49.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) yaml: specifier: ^2.8.3 version: 2.9.0 @@ -3035,6 +3038,26 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + virtua@0.49.2: + resolution: {integrity: sha512-aEp3+6cmIRjHUlQnWdgGXYMYtrIG26QnN9jJDZEE5LRhvo1Z9HzoJwLDgyVULUPWcSdCnZAroQm7raXJyTG0AA==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + vite@8.0.14: resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5964,6 +5987,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + virtua@0.49.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + vite@8.0.14(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 diff --git a/preview-features.json b/preview-features.json index bc5f6d16c0..4c2a426ffd 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,6 +40,14 @@ "platforms": [ "desktop" ] + }, + { + "id": "virtuaTimeline", + "name": "Virtua Timeline", + "description": "Virtualized channel timeline using Virtua", + "platforms": [ + "desktop" + ] } ] } From 49afc501a8b9ef88155943fa4c9fac0a9c0d6b68 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 19:59:40 -0400 Subject: [PATCH 02/53] Polish virtualized channel viewport Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/src/app/AppShell.tsx | 3 - .../src/features/channels/ui/ChannelPane.tsx | 3 +- .../features/messages/ui/MessageTimeline.tsx | 109 +++++++++++++++--- .../messages/ui/TimelineMessageList.tsx | 31 +++-- .../messages/ui/useComposerHeightPadding.ts | 15 ++- preview-features.json | 8 -- 6 files changed, 134 insertions(+), 35 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index ce6151c62a..a9f823e0d1 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -75,7 +75,6 @@ import { useFeatureEnabled } from "@/shared/features"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; -import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock"; import { joinChannel } from "@/shared/api/tauri"; import type { SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; @@ -96,8 +95,6 @@ const LazySettingsScreen = React.lazy(async () => { export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); - const virtuaTimelineEnabled = useFeatureEnabled("virtuaTimeline"); - useWebviewScrollBoundaryLock(!virtuaTimelineEnabled); const workspacesHook = useWorkspaces(); const workspaceRailEnabled = useFeatureEnabled("workspaceRail"); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f7cd01b781..2e448d81f7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -190,6 +190,7 @@ export const ChannelPane = React.memo(function ChannelPane({ timelineScrollRef, composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, + "css-variable", ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { @@ -735,7 +736,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } showTopBorder={false} /> -
+
{hasComposerBotActivity ? (
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 775c5d6fd2..c52be9e2e7 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -13,7 +13,6 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; -import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { Spinner } from "@/shared/ui/spinner"; @@ -199,7 +198,7 @@ const MessageTimelineBase = React.forwardRef< React.useReducer((version: number) => version + 1, 0); const [timelineVirtualizerApi, setTimelineVirtualizerApi] = React.useState(null); - const useTimelineVirtualizer = useFeatureEnabled("virtuaTimeline"); + const useTimelineVirtualizer = true; const activeScrollContainerRef = React.useMemo( () => ({ get current() { @@ -247,7 +246,6 @@ const MessageTimelineBase = React.forwardRef< // painted at a stale offset until the user's next scroll event forces layout. const scrollContainerDomKey = channelId ?? "none"; - // biome-ignore lint/correctness/useExhaustiveDependencies: this effect is scoped to scroll DOM swaps; when the Experiments flag hydrates, the virtualizer child registers its own scroller/API via callbacks, and re-running this reset on the flag transition can briefly clear that API during navigation tests. React.useLayoutEffect(() => { // Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node. void scrollContainerDomKey; @@ -298,16 +296,10 @@ const MessageTimelineBase = React.forwardRef< const showDirectMessageIntro = timelineIntroSurface === "direct-message-intro"; const showChannelIntro = timelineIntroSurface === "channel-intro"; - const activeDirectMessageIntro = - showDirectMessageIntro && - (!useTimelineVirtualizer || timelineBodySurface !== "list") - ? directMessageIntro - : null; - const activeChannelIntro = - showChannelIntro && - (!useTimelineVirtualizer || timelineBodySurface !== "list") - ? channelIntro - : null; + const activeDirectMessageIntro = showDirectMessageIntro + ? directMessageIntro + : null; + const activeChannelIntro = showChannelIntro ? channelIntro : null; const showIntro = activeDirectMessageIntro !== null || activeChannelIntro !== null; const showGenericEmpty = timelineBodySurface === "empty" && !showIntro; @@ -448,6 +440,96 @@ const MessageTimelineBase = React.forwardRef< messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages, }); + const virtualizedLeadingContent = React.useMemo( + () => + activeChannelIntro ? ( +
+
+ {activeChannelIntro.icon ?? ( + + )} +
+

+ #{activeChannelIntro.channelName} +

+

+ This is the beginning of the{" "} + + {activeChannelIntro.channelKindLabel} + + . +

+ {activeChannelIntro.description ? ( +

+ {activeChannelIntro.description} +

+ ) : null} + {activeChannelIntro.actions?.length ? ( +
+ {activeChannelIntro.actions.map((action) => ( + + ))} +
+ ) : null} +
+ ) : activeDirectMessageIntro ? ( +
+ +

+ {activeDirectMessageIntro.displayName} +

+

+ This is the beginning of your direct message with{" "} + + {activeDirectMessageIntro.displayName} + + . +

+
+ ) : null, + [activeChannelIntro, activeDirectMessageIntro], + ); + const handleVirtualizerRangeChanged = React.useCallback(() => { bumpVirtualizerRenderVersion(); }, []); @@ -469,6 +551,7 @@ const MessageTimelineBase = React.forwardRef< isMessageUnreadById={isMessageUnreadById} messageFooters={messageFooters} mainEntries={deferredMessages === messages ? mainEntries : undefined} + leadingContent={virtualizedLeadingContent} threadSummaries={threadSummaries} messages={deferredMessages} onDelete={onDelete} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index f94785b9ac..18f7f2fec7 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -92,6 +92,8 @@ type TimelineMessageListProps = { searchQuery?: string; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + /** Content rendered as the first virtual row before channel history. */ + leadingContent?: React.ReactNode; /** The virtualized timeline owns its scroll node when enabled. */ useVirtualizer?: boolean; onStartReached?: () => void; @@ -132,6 +134,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, + leadingContent, useVirtualizer = false, onStartReached, onAtBottomStateChange, @@ -283,6 +286,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ return ( [ - { kind: "day-heading" as const, group }, - ...group.items.map((item) => ({ kind: "timeline-item" as const, item })), - ]); + return [ + ...(leadingContent + ? [{ kind: "leading-content" as const, content: leadingContent }] + : []), + ...dayGroups.flatMap((group) => [ + { kind: "day-heading" as const, group }, + ...group.items.map((item) => ({ kind: "timeline-item" as const, item })), + ]), + ]; } type VirtualizedTimelineRowsProps = { dayGroups: TimelineDayGroup[]; + leadingContent?: React.ReactNode; onAtBottomStateChange?: (atBottom: boolean) => void; onStartReached?: () => void; onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; @@ -361,6 +374,7 @@ type VirtualizedTimelineRowsProps = { function VirtualizedTimelineRows({ dayGroups, + leadingContent, onAtBottomStateChange, onStartReached, onVirtualizerApiChange, @@ -372,8 +386,8 @@ function VirtualizedTimelineRows({ const hostRef = React.useRef(null); const hasInitialPositionedRef = React.useRef(false); const items = React.useMemo( - () => buildVirtualizedItems(dayGroups), - [dayGroups], + () => buildVirtualizedItems(dayGroups, leadingContent), + [dayGroups, leadingContent], ); const previousFirstTimelineKeyRef = React.useRef(null); const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); @@ -463,7 +477,7 @@ function VirtualizedTimelineRows({
{(item) => { + if (item.kind === "leading-content") { + return
{item.content}
; + } if (item.kind === "day-heading") { const { group } = item; return ( diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts index fe805ada4f..1c41ae1bf1 100644 --- a/desktop/src/features/messages/ui/useComposerHeightPadding.ts +++ b/desktop/src/features/messages/ui/useComposerHeightPadding.ts @@ -14,6 +14,7 @@ export function useComposerHeightPadding( scrollContainerRef: React.RefObject, composerRef: React.RefObject, resetKey?: unknown, + mode: "padding" | "css-variable" = "padding", ) { React.useEffect(() => { void resetKey; @@ -43,7 +44,11 @@ export function useComposerHeightPadding( const previousPadding = lastPadding; const wasAtBottom = isNearBottom(); - scrollEl.style.paddingBottom = `${padding}px`; + if (mode === "css-variable") { + scrollEl.style.setProperty("--composer-overlay-height", `${padding}px`); + } else { + scrollEl.style.paddingBottom = `${padding}px`; + } lastPadding = padding; if ( @@ -58,7 +63,11 @@ export function useComposerHeightPadding( return () => { disconnect(); - scrollEl.style.paddingBottom = ""; + if (mode === "css-variable") { + scrollEl.style.removeProperty("--composer-overlay-height"); + } else { + scrollEl.style.paddingBottom = ""; + } }; - }, [scrollContainerRef, composerRef, resetKey]); + }, [scrollContainerRef, composerRef, mode, resetKey]); } diff --git a/preview-features.json b/preview-features.json index 4c2a426ffd..bc5f6d16c0 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,14 +40,6 @@ "platforms": [ "desktop" ] - }, - { - "id": "virtuaTimeline", - "name": "Virtua Timeline", - "description": "Virtualized channel timeline using Virtua", - "platforms": [ - "desktop" - ] } ] } From a276ea66c48cc081167c70cf146369e75f91eed3 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 20:14:19 -0400 Subject: [PATCH 03/53] fix(desktop): include overlay clearance in virtual timeline Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- .../src/features/channels/ui/ChannelPane.tsx | 2 +- .../messages/ui/TimelineMessageList.tsx | 26 ++++++- desktop/tests/e2e/scroll-history.spec.ts | 77 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 2e448d81f7..0fd2d28c2c 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -690,7 +690,7 @@ export const ChannelPane = React.memo(function ChannelPane({
) : (
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 18f7f2fec7..06ba96999e 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -329,7 +329,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ }); type VirtualizedTimelineItem = + | { kind: "top-spacer" } | { kind: "leading-content"; content: React.ReactNode } + | { kind: "bottom-spacer" } | { kind: "day-heading"; group: TimelineDayGroup } | { kind: "timeline-item"; item: TimelineNonDayItem }; @@ -340,6 +342,8 @@ function timelineItemMessageId(item: TimelineNonDayItem): string | null { } function virtualizedItemKey(item: VirtualizedTimelineItem): string { + if (item.kind === "top-spacer") return "top-spacer"; + if (item.kind === "bottom-spacer") return "bottom-spacer"; if (item.kind === "leading-content") return "leading-content"; return item.kind === "day-heading" ? `day-${item.group.key}` @@ -351,6 +355,7 @@ function buildVirtualizedItems( leadingContent?: React.ReactNode, ): VirtualizedTimelineItem[] { return [ + { kind: "top-spacer" as const }, ...(leadingContent ? [{ kind: "leading-content" as const, content: leadingContent }] : []), @@ -358,6 +363,7 @@ function buildVirtualizedItems( { kind: "day-heading" as const, group }, ...group.items.map((item) => ({ kind: "timeline-item" as const, item })), ]), + { kind: "bottom-spacer" as const }, ]; } @@ -477,7 +483,7 @@ function VirtualizedTimelineRows({
{(item) => { + if (item.kind === "top-spacer") { + return ( +
+ ); + } + if (item.kind === "bottom-spacer") { + return ( +
+ ); + } if (item.kind === "leading-content") { return
{item.content}
; } diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index ab99b65f03..2cd2dc8862 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1121,6 +1121,83 @@ test("composer expansion does not push bottom row out of viewport", async ({ expect(after.gapAboveComposer).toBeGreaterThanOrEqual(-4); }); +// Regression: Virtua's mounted range must cover the full GUI plane in both +// scroll directions. The composer overlays the timeline; it is not the bottom +// of the viewport. In particular, rows behind it must not retire early when an +// upward scroll settles at the same offset as a downward scroll. +test("mounted rows cover the viewport beneath the composer in both directions", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + await page.evaluate(() => { + for (let index = 0; index < 120; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `direction row ${index}\nsecond line ${index}`, + createdAt: 1_700_000_000 + index, + }); + } + }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline).toContainText("direction row 119"); + await page.waitForFunction(() => { + const element = document.querySelector( + '[data-testid="message-timeline"]', + ); + return element && element.scrollHeight > element.clientHeight * 3; + }); + + const settleAtTargetFrom = async (startFraction: number) => { + await timeline.evaluate((element, fraction) => { + const maxOffset = element.scrollHeight - element.clientHeight; + element.scrollTop = maxOffset * fraction; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }, startFraction); + await page.waitForTimeout(100); + await timeline.evaluate((element) => { + const maxOffset = element.scrollHeight - element.clientHeight; + element.scrollTop = maxOffset / 2; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await expect + .poll(() => + timeline.evaluate((element) => { + const maxOffset = element.scrollHeight - element.clientHeight; + return Math.abs(element.scrollTop - maxOffset / 2); + }), + ) + .toBeLessThan(100); + }; + const bottomCoverage = () => + timeline.evaluate((element) => { + const viewportBottom = element.getBoundingClientRect().bottom; + const mountedRows = Array.from( + element.querySelectorAll("[data-message-id]"), + ); + return Math.max( + ...mountedRows.map( + (row) => row.getBoundingClientRect().bottom - viewportBottom, + ), + ); + }); + + // Arrive from below (upward scroll), then from above (downward scroll). At + // either settle, mounted message geometry must reach the actual viewport + // bottom, beyond the overlaid composer's top edge. + await settleAtTargetFrom(0.75); + await expect.poll(bottomCoverage).toBeGreaterThanOrEqual(0); + await settleAtTargetFrom(0.25); + await expect.poll(bottomCoverage).toBeGreaterThanOrEqual(0); +}); + // Criterion 8: in-viewport content resize while scrolled up preserves the // anchor row's position. // From 0b3782c01ba208e0201a571389ea63d0d2bc5b58 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 20:27:15 -0400 Subject: [PATCH 04/53] fix(desktop): clear Virtua prepend shift after render Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- .../messages/ui/TimelineMessageList.tsx | 32 +++++- desktop/tests/e2e/scroll-history.spec.ts | 99 +++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 06ba96999e..ffe508b03a 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -378,6 +378,18 @@ type VirtualizedTimelineRowsProps = { renderItem: (item: TimelineNonDayItem) => React.ReactNode; }; +function didPrependVirtualizedTimeline( + previousFirstKey: string | null, + firstTimelineKey: string | null, + keys: readonly string[], +): boolean { + return ( + previousFirstKey !== null && + previousFirstKey !== firstTimelineKey && + keys.includes(previousFirstKey) + ); +} + function VirtualizedTimelineRows({ dayGroups, leadingContent, @@ -396,6 +408,13 @@ function VirtualizedTimelineRows({ [dayGroups, leadingContent], ); const previousFirstTimelineKeyRef = React.useRef(null); + const [prependShiftEpoch, clearPrependShift] = React.useReducer( + (version: number) => version + 1, + 0, + ); + // Virtua's `shift` is a one-render instruction, not a persistent mode. If it + // stays true after a prepend, later measurement changes can keep anchoring + // from the end and leave a stale blank range until the next scroll event. const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); const firstTimelineKey = React.useMemo(() => { const first = items.find((item) => item.kind === "timeline-item"); @@ -404,16 +423,21 @@ function VirtualizedTimelineRows({ : null; }, [items]); const isPrepend = React.useMemo(() => { - const previousFirstKey = previousFirstTimelineKeyRef.current; - return previousFirstKey !== null && keys.indexOf(previousFirstKey) > 0; - }, [keys]); + void prependShiftEpoch; + return didPrependVirtualizedTimeline( + previousFirstTimelineKeyRef.current, + firstTimelineKey, + keys, + ); + }, [firstTimelineKey, keys, prependShiftEpoch]); React.useLayoutEffect(() => { previousFirstTimelineKeyRef.current = firstTimelineKey; + if (isPrepend) clearPrependShift(); if (!hasInitialPositionedRef.current && items.length > 0) { hasInitialPositionedRef.current = true; listRef.current?.scrollToIndex(items.length - 1, { align: "end" }); } - }, [firstTimelineKey, items.length]); + }, [firstTimelineKey, isPrepend, items.length]); const messageItemIndexById = React.useMemo(() => { const byId = new Map(); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 2cd2dc8862..c3c76ad174 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1198,6 +1198,105 @@ test("mounted rows cover the viewport beneath the composer in both directions", await expect.poll(bottomCoverage).toBeGreaterThanOrEqual(0); }); +// Regression: after a real prepend, Virtua's `shift` instruction must not stay +// enabled while the reader later fast-scrolls through the middle of the list. +// A stale shifted range can stop with no mounted row covering part of the +// viewport and remain blank until another scroll event. The idle samples below +// deliberately dispatch no follow-up scroll. +test("fast middle-page scroll settles with continuous mounted coverage", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function", + ); + + await page.evaluate(() => { + for (let index = 0; index < 180; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `settle row ${index}\nline two ${index}\nline three ${index}`, + createdAt: 1_700_000_000 + index, + }); + } + }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline).toContainText("settle row 179"); + await page.waitForFunction(() => { + const element = document.querySelector( + '[data-testid="message-timeline"]', + ); + return element && element.scrollHeight > element.clientHeight * 3; + }); + + // Land a genuine prepend first. This is what turns `shift` on; subsequent + // ordinary list updates and measurements must happen with it cleared. + const scrollHeightBeforePrepend = (await getTimelineMetrics(page)) + .scrollHeight; + await page.evaluate(() => { + for (let index = 0; index < 100; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `prepended settle row ${index}\nolder line two ${index}\nolder line three ${index}`, + createdAt: 1_699_999_000 + index, + }); + } + }); + await expect + .poll(() => + getTimelineMetrics(page).then((metrics) => metrics.scrollHeight), + ) + .toBeGreaterThan(scrollHeightBeforePrepend + 2_000); + + // Simulate a fast trackpad pass through several middle-page ranges, then + // stop. The final evaluate emits the last scroll event; all coverage samples + // after it are passive observations. + await timeline.evaluate((element) => { + const maxOffset = element.scrollHeight - element.clientHeight; + for (const fraction of [0.72, 0.28, 0.64, 0.36, 0.58, 0.44, 0.52]) { + element.scrollTop = maxOffset * fraction; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + } + }); + await page.waitForTimeout(250); + + const viewportCoverage = () => + timeline.evaluate((element) => { + const viewport = element.getBoundingClientRect(); + const rows = Array.from( + element.querySelectorAll("[data-message-id]"), + ) + .map((row) => row.getBoundingClientRect()) + .filter( + (rect) => rect.bottom > viewport.top && rect.top < viewport.bottom, + ) + .sort((left, right) => left.top - right.top); + if (rows.length === 0) return Number.POSITIVE_INFINITY; + + let cursor = viewport.top; + let largestGap = Math.max(0, rows[0].top - viewport.top); + for (const row of rows) { + largestGap = Math.max(largestGap, row.top - cursor); + cursor = Math.max(cursor, row.bottom); + } + return Math.max(largestGap, viewport.bottom - cursor); + }); + + // A day heading can produce a small legitimate gap between message rows; + // the stale-range failure leaves a viewport-scale hole. Check repeatedly + // while idle so a transient good frame cannot mask a stuck blank range. + for (let sample = 0; sample < 5; sample += 1) { + expect(await viewportCoverage()).toBeLessThan(100); + await page.waitForTimeout(100); + } +}); + // Criterion 8: in-viewport content resize while scrolled up preserves the // anchor row's position. // From 045727f3f221e7fc482e5076096532286e70e176 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 21:56:32 -0400 Subject: [PATCH 05/53] fix(desktop): anchor stationary timeline reflows Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- .../messages/ui/TimelineMessageList.tsx | 65 +++++++++++++++++ desktop/tests/e2e/virtualization.spec.ts | 71 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index ffe508b03a..af4fbf7bad 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -402,6 +402,11 @@ function VirtualizedTimelineRows({ }: VirtualizedTimelineRowsProps) { const listRef = React.useRef(null); const hostRef = React.useRef(null); + const settledAnchorRef = React.useRef<{ + messageId: string; + top: number; + } | null>(null); + const isScrollingRef = React.useRef(false); const hasInitialPositionedRef = React.useRef(false); const items = React.useMemo( () => buildVirtualizedItems(dayGroups, leadingContent), @@ -490,8 +495,24 @@ function VirtualizedTimelineRows({ return () => onVirtualizerApiChange(null); }, [items.length, messageItemIndexById, onVirtualizerApiChange]); + const captureSettledAnchor = React.useCallback(() => { + const scroller = hostRef.current?.firstElementChild; + if (!(scroller instanceof HTMLDivElement)) return; + const scrollerRect = scroller.getBoundingClientRect(); + const anchor = Array.from( + scroller.querySelectorAll("[data-message-id]"), + ).find((row) => row.getBoundingClientRect().top >= scrollerRect.top); + settledAnchorRef.current = anchor + ? { + messageId: anchor.dataset.messageId ?? "", + top: anchor.getBoundingClientRect().top - scrollerRect.top, + } + : null; + }, []); + const handleScroll = React.useCallback( (offset: number) => { + isScrollingRef.current = true; const list = listRef.current; if (!list) return; onVirtualizerRangeChanged?.(); @@ -503,6 +524,49 @@ function VirtualizedTimelineRows({ [onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged], ); + const handleScrollEnd = React.useCallback(() => { + isScrollingRef.current = false; + captureSettledAnchor(); + }, [captureSettledAnchor]); + + React.useLayoutEffect(() => { + const scroller = hostRef.current?.firstElementChild; + const content = scroller?.firstElementChild; + if ( + !(scroller instanceof HTMLDivElement) || + !(content instanceof HTMLElement) + ) { + return; + } + captureSettledAnchor(); + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + if (isScrollingRef.current) return; + const anchor = settledAnchorRef.current; + if (!anchor) return; + const row = scroller.querySelector( + `[data-message-id="${CSS.escape(anchor.messageId)}"]`, + ); + if (!row) return; + const nextTop = + row.getBoundingClientRect().top - + scroller.getBoundingClientRect().top; + const delta = nextTop - anchor.top; + if (Math.abs(delta) > 0.5) scroller.scrollTop += delta; + }); + }); + const observeItems = () => { + for (const item of content.children) resizeObserver.observe(item); + }; + observeItems(); + const mutationObserver = new MutationObserver(observeItems); + mutationObserver.observe(content, { childList: true }); + return () => { + mutationObserver.disconnect(); + resizeObserver.disconnect(); + }; + }, [captureSettledAnchor]); + return (
{(item) => { if (item.kind === "top-spacer") { diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index 1270e51ddd..5d83f3bd32 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -306,3 +306,74 @@ test.describe("list virtualization", () => { } }); }); + +test("stationary rich-row resize preserves the visible reading anchor", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + await page.evaluate(() => { + for (let index = 0; index < 240; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: [ + `rich resize row ${index}`, + "long wrapped text ".repeat((index % 8) + 1), + ].join("\n"), + createdAt: 1_700_700_000 + index, + }); + } + }); + + await page.getByTestId("channel-general").click(); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + + const result = await timeline.evaluate(async (element) => { + const scroller = element as HTMLDivElement; + scroller.scrollTop = scroller.scrollHeight / 2; + scroller.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const scrollerRect = scroller.getBoundingClientRect(); + const rows = Array.from( + scroller.querySelectorAll("[data-message-id]"), + ); + const visibleRows = rows.filter((row) => { + const rect = row.getBoundingClientRect(); + return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; + }); + const anchor = + visibleRows.find( + (row) => row.getBoundingClientRect().top >= scrollerRect.top, + ) ?? visibleRows[0]; + if (!anchor) throw new Error("no visible reading anchor"); + const rowAbove = rows + .filter( + (row) => + row.getBoundingClientRect().bottom <= + anchor.getBoundingClientRect().top, + ) + .at(-1); + if (!rowAbove) throw new Error("no mounted rich row above anchor"); + + const anchorTop = anchor.getBoundingClientRect().top - scrollerRect.top; + const scrollTop = scroller.scrollTop; + rowAbove.style.minHeight = `${rowAbove.getBoundingClientRect().height + 240}px`; + await new Promise((resolve) => setTimeout(resolve, 500)); + + return { + anchorDrift: + anchor.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + anchorTop, + scrollCorrection: scroller.scrollTop - scrollTop, + }; + }); + + expect(Math.abs(result.anchorDrift)).toBeLessThanOrEqual(2); + expect(result.scrollCorrection).toBeGreaterThanOrEqual(238); +}); From 536db132e70cc21237ccb5943b47826eb5036fb1 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 22:17:24 -0400 Subject: [PATCH 06/53] fix(desktop): bound timeline window and preserve live threads Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- .../messages/ui/TimelineMessageList.tsx | 14 +---- .../src/features/messages/useThreadReplies.ts | 62 +++++++++++++----- desktop/src/testing/e2eBridge.ts | 9 ++- desktop/tests/e2e/messaging.spec.ts | 57 +++++++++++++++++ desktop/tests/e2e/scroll-history.spec.ts | 53 +++++----------- desktop/tests/e2e/virtualization.spec.ts | 63 +++++++++++++++++++ desktop/tests/helpers/bridge.ts | 3 + 7 files changed, 196 insertions(+), 65 deletions(-) diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index af4fbf7bad..e6588e991f 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -453,17 +453,6 @@ function VirtualizedTimelineRows({ }); return byId; }, [items]); - const persistentIndexes = React.useMemo( - () => - items.flatMap((item, index) => - item.kind === "timeline-item" && - item.item.kind === "message" && - item.item.entry.summary - ? [index] - : [], - ), - [items], - ); React.useLayoutEffect(() => { const scroller = hostRef.current?.firstElementChild; @@ -573,8 +562,7 @@ function VirtualizedTimelineRows({ ref={listRef} className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain px-2" data={items} - bufferSize={1_500} - keepMounted={persistentIndexes} + bufferSize={1_000} shift={isPrepend} onScroll={handleScroll} onScrollEnd={handleScrollEnd} diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 3c993f5139..abdb4f034a 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -1,10 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { collectMessageIdsForAuxBackfill, fetchStructuralAuxForMessages, } from "@/features/messages/lib/auxBackfill"; -import { threadRepliesKey } from "@/features/messages/lib/messageQueryKeys"; +import { + threadRepliesKey, + sortMessages, +} from "@/features/messages/lib/messageQueryKeys"; +import { relayClient } from "@/shared/api/relayClient"; +import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; import { getThreadReplies } from "@/shared/api/tauri"; import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; @@ -18,26 +23,43 @@ const MAX_THREAD_PAGES = 500; * original text. Best-effort: an aux failure logs and returns the replies * unadorned rather than failing the whole thread load. */ -async function withStructuralAux( +async function fetchThreadAuxBestEffort( + label: string, channelId: string, - replies: RelayEvent[], + fetchAux: () => Promise, ): Promise { try { - const auxEvents = await fetchStructuralAuxForMessages( - channelId, - collectMessageIdsForAuxBackfill(replies), - ); - return auxEvents.length > 0 ? [...replies, ...auxEvents] : replies; + return await fetchAux(); } catch (error) { console.error( - "Failed to backfill thread reply edits for channel", + `Failed to backfill thread reply ${label} for channel`, channelId, error, ); - return replies; + return []; } } +async function withThreadAux( + channelId: string, + replies: RelayEvent[], +): Promise { + const messageIds = collectMessageIdsForAuxBackfill(replies); + const [structuralAux, reactions] = await Promise.all([ + fetchThreadAuxBestEffort("structural aux", channelId, () => + fetchStructuralAuxForMessages(channelId, messageIds), + ), + fetchThreadAuxBestEffort("reactions", channelId, () => + relayClient.fetchAuxEventsByReference( + channelId, + messageIds, + buildChannelReactionAuxFilter, + ), + ), + ]); + return sortMessages([...replies, ...structuralAux, ...reactions]); +} + /** Fetch a thread subtree into a cache independent from channel window pages. */ export function useThreadReplies( activeChannel: Channel | null, @@ -45,14 +67,19 @@ export function useThreadReplies( ) { const channelId = activeChannel?.id ?? "none"; const rootId = openThreadRootId ?? "none"; + const queryClient = useQueryClient(); + const queryKey = threadRepliesKey(channelId, rootId); return useQuery({ - queryKey: threadRepliesKey(channelId, rootId), + queryKey, enabled: activeChannel !== null && activeChannel.channelType !== "forum" && openThreadRootId !== null, queryFn: async (): Promise => { if (!activeChannel || !openThreadRootId) return []; + const cacheAtStart = + queryClient.getQueryData(queryKey) ?? []; + const idsAtStart = new Set(cacheAtStart.map((event) => event.id)); const replies: RelayEvent[] = []; let cursor: ThreadCursor | null = null; for (let page = 0; page < MAX_THREAD_PAGES; page += 1) { @@ -62,8 +89,15 @@ export function useThreadReplies( { limit: THREAD_PAGE_LIMIT, cursor }, ); replies.push(...response.events); - if (!response.nextCursor) - return withStructuralAux(activeChannel.id, replies); + if (!response.nextCursor) { + const fetched = await withThreadAux(activeChannel.id, replies); + const current = + queryClient.getQueryData(queryKey) ?? []; + const receivedInFlight = current.filter( + (event) => !idsAtStart.has(event.id), + ); + return sortMessages([...fetched, ...receivedInFlight]); + } cursor = response.nextCursor; } throw new Error( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 12c96612dd..06b3aaa0aa 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -128,6 +128,9 @@ type E2eConfig = { applyWorkspaceDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; + /** Delay (ms) after snapshotting a thread-replies page so E2E tests can + * deliver live reply/aux events while an older response is in flight. */ + threadRepliesDelayMs?: number; usersBatchDelayMs?: number; /** Delay (ms) applied to continuation channel-window requests so e2e * tests can observe the in-flight prepend window. 0/undefined = instant. */ @@ -3663,6 +3666,10 @@ async function handleGetThreadReplies( event_id: page[page.length - 1].id, } : null; + const delayMs = config?.mock?.threadRepliesDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } return { events: page, next_cursor: nextCursor }; } @@ -7171,7 +7178,7 @@ async function handleSendChannelMessage( : 1; const event: RelayEvent = { - id: crypto.randomUUID().replace(/-/g, ""), + id: mockEventId(), pubkey: mockPubkey, created_at: createdAt, kind, diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 1c72021251..a0ba529673 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1033,6 +1033,63 @@ test("thread composer is focused after clicking the reply icon", async ({ await expect(threadInput).toHaveText("typed-into-thread"); }); +test("thread refetch preserves a live reply and reaction received in flight", async ({ + page, +}) => { + await installMockBridge(page, { threadRepliesDelayMs: 800 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const rootMessage = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .first(); + const rootId = await rootMessage.getAttribute("data-message-id"); + if (!rootId) throw new Error("Expected a thread root id."); + + await rootMessage.hover(); + await rootMessage.getByRole("button", { name: "Reply" }).click(); + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + + const reply = `Live reply during thread fetch ${Date.now()}`; + const replyId = await page.evaluate( + async ({ channelId, content, parentEventId }) => { + const bridgeWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + }; + const invoke = bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable."); + const sent = (await invoke("send_channel_message", { + channelId, + content, + parentEventId, + })) as { event_id: string }; + await invoke("add_reaction", { eventId: sent.event_id, emoji: "👍" }); + return sent.event_id; + }, + { + channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + content: reply, + parentEventId: rootId, + }, + ); + + const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`); + await expect(replyRow).toContainText(reply); + await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible(); + + // The delayed get_thread_replies response was snapshotted before the live + // events. Wait past query completion: neither cache addition may disappear. + await page.waitForTimeout(1_200); + await expect(replyRow).toContainText(reply); + await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible(); +}); + test("thread composer keeps focus after sending a thread reply", async ({ page, }) => { diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index c3c76ad174..f154cbcee0 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -2021,16 +2021,11 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as ); }); -// Regression: thread-summary badges must not flash during a scrollback prepend. -// When an older page lands, the urgent render pass still paints the OLD -// deferred message snapshot, so MessageTimeline passes `mainEntries=undefined` -// and TimelineMessageList rebuilds entries itself. That fallback used to drop -// the relay summary map — and since thread replies are usually not local -// timeline rows, every relay-driven badge unmounted for the whole deferred -// window and remounted when the heavy render committed (the visible flash). -// A MutationObserver is commit-granular, so it catches even a single-frame -// unmount that a polling assertion would race past. -test("thread summary badge survives an older-history prepend without unmounting", async ({ +// Regression: relay-backed thread summaries must survive in timeline data when +// their virtual rows unmount during scrollback. The row is expected to leave the +// DOM outside the bounded window, then render once (with the same summary) when +// the reader returns. +test("thread summary badge survives a virtualized older-history prepend", async ({ page, }, testInfo) => { testInfo.setTimeout(60_000); @@ -2074,22 +2069,6 @@ test("thread summary badge survives an older-history prepend without unmounting" const oldestBefore = await oldestRenderedIndex(); expect(oldestBefore).not.toBeNull(); - // Arm the observer AFTER the initial window has settled, so the only - // mutations it sees are the prepend landing and any (buggy) badge unmount. - await timeline.evaluate((element, selector) => { - const scroller = element as HTMLDivElement; - const win = window as typeof window & { - __SUMMARY_FLASH_PROBE__?: { missingCommits: number }; - }; - const probe = { missingCommits: 0 }; - win.__SUMMARY_FLASH_PROBE__ = probe; - new MutationObserver(() => { - if (!scroller.querySelector(selector)) { - probe.missingCommits += 1; - } - }).observe(scroller, { childList: true, subtree: true }); - }, badgeSelector); - // Scroll back until a genuinely older page has landed. await timeline.hover(); await expect @@ -2103,16 +2082,16 @@ test("thread summary badge survives an older-history prepend without unmounting" ) .toBeLessThan(oldestBefore ?? Number.POSITIVE_INFINITY); - // The badge row must have stayed mounted through every DOM commit of the - // prepend — zero commits observed it missing — and still be attached now. - const missingCommits = await page.evaluate( - () => - ( - window as typeof window & { - __SUMMARY_FLASH_PROBE__?: { missingCommits: number }; - } - ).__SUMMARY_FLASH_PROBE__?.missingCommits, - ); - expect(missingCommits).toBe(0); + // Newer history, including the summary row, is no longer pinned offscreen. + await expect(timeline.locator(badgeSelector)).toHaveCount(0); + + // Returning to the bottom remounts the row from stable timeline data; no + // persistent DOM node is required for summary correctness. + await timeline.evaluate((element) => { + const scroller = element as HTMLDivElement; + scroller.scrollTop = scroller.scrollHeight; + scroller.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await expect(timeline.locator(badgeSelector)).toBeVisible(); await expect(timeline.locator(badgeSelector)).toHaveCount(1); }); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index 5d83f3bd32..a6d8e18374 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -307,6 +307,69 @@ test.describe("list virtualization", () => { }); }); +test("thread-heavy history keeps a bounded mounted window", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + // Seed summaries on 120 loaded roots. `keepMounted` previously pinned every + // one of these rows forever, so scrolling into older history retained the + // newer summary rows and let the DOM grow with every loaded page. + await page.evaluate(() => { + for (let index = 480; index < 600; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "deep-history", + content: `summary-only reply ${index}`, + parentEventId: `mock-deep-history-${index}`, + }); + } + }); + + await page.getByTestId("channel-deep-history").click(); + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + + // Emit after opening so the live summary path updates every loaded root, + // independent of the relay page's summary cap. + await page.evaluate(() => { + for (let index = 480; index < 600; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "deep-history", + content: `live summary-only reply ${index}`, + parentEventId: `mock-deep-history-${index}`, + }); + } + }); + await page.waitForTimeout(500); + + await timeline.evaluate(async (element) => { + const scroller = element as HTMLDivElement; + // Walk through the loaded thread-heavy range so every summary row has been + // mounted at least once. The old keepMounted policy retained all of them. + for ( + let offset = scroller.scrollHeight; + offset > scroller.clientHeight; + offset -= scroller.clientHeight + ) { + scroller.scrollTop = offset; + scroller.dispatchEvent(new Event("scroll", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + scroller.scrollTop = scroller.scrollHeight / 3; + scroller.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await page.waitForTimeout(300); + + const mounted = timeline.locator("[data-message-id]"); + await expect(mounted).not.toHaveCount(0); + const mountedCount = await mounted.count(); + expect(mountedCount).toBeLessThan(30); +}); + test("stationary rich-row resize preserves the visible reading anchor", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c5f2f0c323..8b457e6a6b 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -130,6 +130,9 @@ type MockBridgeOptions = { applyWorkspaceDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; + /** Delay (ms) after snapshotting a thread-replies page so E2E tests can + * deliver live reply/aux events while an older response is in flight. */ + threadRepliesDelayMs?: number; usersBatchDelayMs?: number; /** Delay (ms) for older-history fetches; see e2eBridge mock config. */ channelWindowDelayMs?: number; From 1b82f205ef73355790118b3f02ae70659ec96796 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 22:45:10 -0400 Subject: [PATCH 07/53] fix(desktop): prerender timeline beyond viewport Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- .../messages/ui/TimelineMessageList.tsx | 17 +++++++++- desktop/tests/e2e/scroll-history.spec.ts | 33 +++++++++++++------ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index e6588e991f..dd406f358b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -402,6 +402,7 @@ function VirtualizedTimelineRows({ }: VirtualizedTimelineRowsProps) { const listRef = React.useRef(null); const hostRef = React.useRef(null); + const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(2_000); const settledAnchorRef = React.useRef<{ messageId: string; top: number; @@ -484,6 +485,20 @@ function VirtualizedTimelineRows({ return () => onVirtualizerApiChange(null); }, [items.length, messageItemIndexById, onVirtualizerApiChange]); + React.useLayoutEffect(() => { + const host = hostRef.current; + if (!host) return; + const updateBufferSize = () => { + // Keep two complete viewports mounted on either side so variable-height + // rows can render and settle before the user scrolls them into view. + setOffscreenBufferSize(Math.max(2_000, host.clientHeight * 2)); + }; + updateBufferSize(); + const resizeObserver = new ResizeObserver(updateBufferSize); + resizeObserver.observe(host); + return () => resizeObserver.disconnect(); + }, []); + const captureSettledAnchor = React.useCallback(() => { const scroller = hostRef.current?.firstElementChild; if (!(scroller instanceof HTMLDivElement)) return; @@ -562,7 +577,7 @@ function VirtualizedTimelineRows({ ref={listRef} className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain px-2" data={items} - bufferSize={1_000} + bufferSize={offscreenBufferSize} shift={isPrepend} onScroll={handleScroll} onScrollEnd={handleScrollEnd} diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index f154cbcee0..229ef06946 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1176,26 +1176,39 @@ test("mounted rows cover the viewport beneath the composer in both directions", ) .toBeLessThan(100); }; - const bottomCoverage = () => + const mountedCoverage = () => timeline.evaluate((element) => { - const viewportBottom = element.getBoundingClientRect().bottom; + const viewport = element.getBoundingClientRect(); const mountedRows = Array.from( element.querySelectorAll("[data-message-id]"), ); - return Math.max( - ...mountedRows.map( - (row) => row.getBoundingClientRect().bottom - viewportBottom, - ), - ); + return { + above: viewport.top - mountedRows[0].getBoundingClientRect().top, + below: + mountedRows[mountedRows.length - 1].getBoundingClientRect().bottom - + viewport.bottom, + viewportHeight: viewport.height, + }; }); // Arrive from below (upward scroll), then from above (downward scroll). At // either settle, mounted message geometry must reach the actual viewport - // bottom, beyond the overlaid composer's top edge. + // bottom, beyond the overlaid composer's top edge. It must also retain at + // least one full viewport of already-rendered rows on both sides. await settleAtTargetFrom(0.75); - await expect.poll(bottomCoverage).toBeGreaterThanOrEqual(0); + await expect + .poll(async () => { + const coverage = await mountedCoverage(); + return Math.min(coverage.above, coverage.below) / coverage.viewportHeight; + }) + .toBeGreaterThanOrEqual(1); await settleAtTargetFrom(0.25); - await expect.poll(bottomCoverage).toBeGreaterThanOrEqual(0); + await expect + .poll(async () => { + const coverage = await mountedCoverage(); + return Math.min(coverage.above, coverage.below) / coverage.viewportHeight; + }) + .toBeGreaterThanOrEqual(1); }); // Regression: after a real prepend, Virtua's `shift` instruction must not stay From 8c8c7a99f2883d67f48a5fd045fddc1282a331f9 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 22:55:50 -0400 Subject: [PATCH 08/53] fix(desktop): anchor timeline measurements at center Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- .../messages/ui/TimelineMessageList.tsx | 57 ++++++++++++------- desktop/tests/e2e/virtualization.spec.ts | 47 +++++++++------ 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index dd406f358b..7ab25e7a77 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -403,9 +403,9 @@ function VirtualizedTimelineRows({ const listRef = React.useRef(null); const hostRef = React.useRef(null); const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(2_000); - const settledAnchorRef = React.useRef<{ + const centerAnchorRef = React.useRef<{ messageId: string; - top: number; + centerOffset: number; } | null>(null); const isScrollingRef = React.useRef(false); const hasInitialPositionedRef = React.useRef(false); @@ -499,19 +499,32 @@ function VirtualizedTimelineRows({ return () => resizeObserver.disconnect(); }, []); - const captureSettledAnchor = React.useCallback(() => { + const captureCenterAnchor = React.useCallback(() => { const scroller = hostRef.current?.firstElementChild; if (!(scroller instanceof HTMLDivElement)) return; const scrollerRect = scroller.getBoundingClientRect(); - const anchor = Array.from( - scroller.querySelectorAll("[data-message-id]"), - ).find((row) => row.getBoundingClientRect().top >= scrollerRect.top); - settledAnchorRef.current = anchor - ? { - messageId: anchor.dataset.messageId ?? "", - top: anchor.getBoundingClientRect().top - scrollerRect.top, - } - : null; + const viewportCenter = scrollerRect.top + scroller.clientHeight / 2; + let anchor: HTMLElement | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const row of scroller.querySelectorAll( + "[data-message-id]", + )) { + const rect = row.getBoundingClientRect(); + if (rect.bottom < scrollerRect.top || rect.top > scrollerRect.bottom) { + continue; + } + const distance = Math.abs((rect.top + rect.bottom) / 2 - viewportCenter); + if (distance < nearestDistance) { + anchor = row; + nearestDistance = distance; + } + } + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + centerAnchorRef.current = { + messageId: anchor.dataset.messageId ?? "", + centerOffset: (rect.top + rect.bottom) / 2 - viewportCenter, + }; }, []); const handleScroll = React.useCallback( @@ -530,8 +543,8 @@ function VirtualizedTimelineRows({ const handleScrollEnd = React.useCallback(() => { isScrollingRef.current = false; - captureSettledAnchor(); - }, [captureSettledAnchor]); + captureCenterAnchor(); + }, [captureCenterAnchor]); React.useLayoutEffect(() => { const scroller = hostRef.current?.firstElementChild; @@ -542,20 +555,22 @@ function VirtualizedTimelineRows({ ) { return; } - captureSettledAnchor(); + captureCenterAnchor(); const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { if (isScrollingRef.current) return; - const anchor = settledAnchorRef.current; + const anchor = centerAnchorRef.current; if (!anchor) return; const row = scroller.querySelector( `[data-message-id="${CSS.escape(anchor.messageId)}"]`, ); if (!row) return; - const nextTop = - row.getBoundingClientRect().top - - scroller.getBoundingClientRect().top; - const delta = nextTop - anchor.top; + const scrollerRect = scroller.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + const viewportCenter = scrollerRect.top + scroller.clientHeight / 2; + const nextCenterOffset = + (rowRect.top + rowRect.bottom) / 2 - viewportCenter; + const delta = nextCenterOffset - anchor.centerOffset; if (Math.abs(delta) > 0.5) scroller.scrollTop += delta; }); }); @@ -569,7 +584,7 @@ function VirtualizedTimelineRows({ mutationObserver.disconnect(); resizeObserver.disconnect(); }; - }, [captureSettledAnchor]); + }, [captureCenterAnchor]); return (
diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index a6d8e18374..dd7593d094 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -367,10 +367,13 @@ test("thread-heavy history keeps a bounded mounted window", async ({ const mounted = timeline.locator("[data-message-id]"); await expect(mounted).not.toHaveCount(0); const mountedCount = await mounted.count(); - expect(mountedCount).toBeLessThan(30); + // Two viewports of real overscan intentionally mounts more than the old + // sub-30 window, while still evicting a substantial part of this 120-row + // history after every row has been visited. + expect(mountedCount).toBeLessThan(80); }); -test("stationary rich-row resize preserves the visible reading anchor", async ({ +test("offscreen rich-row resize preserves the viewport-center anchor", async ({ page, }) => { await installMockBridge(page); @@ -409,30 +412,40 @@ test("stationary rich-row resize preserves the visible reading anchor", async ({ const rect = row.getBoundingClientRect(); return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; }); - const anchor = - visibleRows.find( - (row) => row.getBoundingClientRect().top >= scrollerRect.top, - ) ?? visibleRows[0]; - if (!anchor) throw new Error("no visible reading anchor"); + const viewportCenter = scrollerRect.top + scroller.clientHeight / 2; + const anchor = visibleRows.reduce((nearest, row) => { + if (!nearest) return row; + const rowRect = row.getBoundingClientRect(); + const nearestRect = nearest.getBoundingClientRect(); + const rowDistance = Math.abs( + (rowRect.top + rowRect.bottom) / 2 - viewportCenter, + ); + const nearestDistance = Math.abs( + (nearestRect.top + nearestRect.bottom) / 2 - viewportCenter, + ); + return rowDistance < nearestDistance ? row : nearest; + }, null); + if (!anchor) throw new Error("no visible center anchor"); const rowAbove = rows - .filter( - (row) => - row.getBoundingClientRect().bottom <= - anchor.getBoundingClientRect().top, - ) + .filter((row) => row.getBoundingClientRect().bottom <= scrollerRect.top) .at(-1); - if (!rowAbove) throw new Error("no mounted rich row above anchor"); + if (!rowAbove) throw new Error("no mounted offscreen rich row above"); - const anchorTop = anchor.getBoundingClientRect().top - scrollerRect.top; + const anchorRect = anchor.getBoundingClientRect(); + const anchorCenterOffset = + (anchorRect.top + anchorRect.bottom) / 2 - viewportCenter; const scrollTop = scroller.scrollTop; rowAbove.style.minHeight = `${rowAbove.getBoundingClientRect().height + 240}px`; await new Promise((resolve) => setTimeout(resolve, 500)); + const nextScrollerRect = scroller.getBoundingClientRect(); + const nextAnchorRect = anchor.getBoundingClientRect(); + const nextViewportCenter = nextScrollerRect.top + scroller.clientHeight / 2; return { anchorDrift: - anchor.getBoundingClientRect().top - - scroller.getBoundingClientRect().top - - anchorTop, + (nextAnchorRect.top + nextAnchorRect.bottom) / 2 - + nextViewportCenter - + anchorCenterOffset, scrollCorrection: scroller.scrollTop - scrollTop, }; }); From 1dab5e8810d3c221ba8763e91e4cc3ec22db44b7 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 23:08:01 -0400 Subject: [PATCH 09/53] fix(desktop): let Virtua own timeline anchoring Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- .../messages/ui/TimelineMessageList.tsx | 91 ++----------------- 1 file changed, 7 insertions(+), 84 deletions(-) diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 7ab25e7a77..dd6923fc53 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -402,12 +402,9 @@ function VirtualizedTimelineRows({ }: VirtualizedTimelineRowsProps) { const listRef = React.useRef(null); const hostRef = React.useRef(null); - const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(2_000); - const centerAnchorRef = React.useRef<{ - messageId: string; - centerOffset: number; - } | null>(null); - const isScrollingRef = React.useRef(false); + const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(() => + typeof window === "undefined" ? 1_000 : window.innerHeight, + ); const hasInitialPositionedRef = React.useRef(false); const items = React.useMemo( () => buildVirtualizedItems(dayGroups, leadingContent), @@ -489,9 +486,9 @@ function VirtualizedTimelineRows({ const host = hostRef.current; if (!host) return; const updateBufferSize = () => { - // Keep two complete viewports mounted on either side so variable-height - // rows can render and settle before the user scrolls them into view. - setOffscreenBufferSize(Math.max(2_000, host.clientHeight * 2)); + // Keep one complete viewport mounted on either side so variable-height + // rows can settle before they enter view without overworking measurement. + setOffscreenBufferSize(host.clientHeight); }; updateBufferSize(); const resizeObserver = new ResizeObserver(updateBufferSize); @@ -499,37 +496,8 @@ function VirtualizedTimelineRows({ return () => resizeObserver.disconnect(); }, []); - const captureCenterAnchor = React.useCallback(() => { - const scroller = hostRef.current?.firstElementChild; - if (!(scroller instanceof HTMLDivElement)) return; - const scrollerRect = scroller.getBoundingClientRect(); - const viewportCenter = scrollerRect.top + scroller.clientHeight / 2; - let anchor: HTMLElement | null = null; - let nearestDistance = Number.POSITIVE_INFINITY; - for (const row of scroller.querySelectorAll( - "[data-message-id]", - )) { - const rect = row.getBoundingClientRect(); - if (rect.bottom < scrollerRect.top || rect.top > scrollerRect.bottom) { - continue; - } - const distance = Math.abs((rect.top + rect.bottom) / 2 - viewportCenter); - if (distance < nearestDistance) { - anchor = row; - nearestDistance = distance; - } - } - if (!anchor) return; - const rect = anchor.getBoundingClientRect(); - centerAnchorRef.current = { - messageId: anchor.dataset.messageId ?? "", - centerOffset: (rect.top + rect.bottom) / 2 - viewportCenter, - }; - }, []); - const handleScroll = React.useCallback( (offset: number) => { - isScrollingRef.current = true; const list = listRef.current; if (!list) return; onVirtualizerRangeChanged?.(); @@ -541,51 +509,6 @@ function VirtualizedTimelineRows({ [onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged], ); - const handleScrollEnd = React.useCallback(() => { - isScrollingRef.current = false; - captureCenterAnchor(); - }, [captureCenterAnchor]); - - React.useLayoutEffect(() => { - const scroller = hostRef.current?.firstElementChild; - const content = scroller?.firstElementChild; - if ( - !(scroller instanceof HTMLDivElement) || - !(content instanceof HTMLElement) - ) { - return; - } - captureCenterAnchor(); - const resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(() => { - if (isScrollingRef.current) return; - const anchor = centerAnchorRef.current; - if (!anchor) return; - const row = scroller.querySelector( - `[data-message-id="${CSS.escape(anchor.messageId)}"]`, - ); - if (!row) return; - const scrollerRect = scroller.getBoundingClientRect(); - const rowRect = row.getBoundingClientRect(); - const viewportCenter = scrollerRect.top + scroller.clientHeight / 2; - const nextCenterOffset = - (rowRect.top + rowRect.bottom) / 2 - viewportCenter; - const delta = nextCenterOffset - anchor.centerOffset; - if (Math.abs(delta) > 0.5) scroller.scrollTop += delta; - }); - }); - const observeItems = () => { - for (const item of content.children) resizeObserver.observe(item); - }; - observeItems(); - const mutationObserver = new MutationObserver(observeItems); - mutationObserver.observe(content, { childList: true }); - return () => { - mutationObserver.disconnect(); - resizeObserver.disconnect(); - }; - }, [captureCenterAnchor]); - return (
{(item) => { if (item.kind === "top-spacer") { From 59ff2c5bf4e51d4e2ad15eab0a5c13e36020895e Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Fri, 10 Jul 2026 00:02:52 -0400 Subject: [PATCH 10/53] Replace timeline virtualization with retained windows Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/package.json | 1 - .../src/features/channels/ui/ChannelPane.tsx | 1 - .../features/messages/ui/MessageTimeline.tsx | 585 +++++++----------- .../messages/ui/TimelineMessageList.tsx | 323 +--------- .../features/messages/ui/useAnchoredScroll.ts | 165 +---- .../ui/useRetainedTimelineWindow.test.mjs | 54 ++ .../messages/ui/useRetainedTimelineWindow.ts | 260 ++++++++ desktop/tests/e2e/virtualization.spec.ts | 8 +- pnpm-lock.yaml | 28 - 9 files changed, 560 insertions(+), 865 deletions(-) create mode 100644 desktop/src/features/messages/ui/useRetainedTimelineWindow.test.mjs create mode 100644 desktop/src/features/messages/ui/useRetainedTimelineWindow.ts diff --git a/desktop/package.json b/desktop/package.json index e2bb06d475..ed17b84e33 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -77,7 +77,6 @@ "tailwind-merge": "^3.5.0", "tiptap-markdown": "^0.9.0", "upng-js": "^2.1.0", - "virtua": "0.49.2", "yaml": "^2.8.3", "zod": "^4.4.3" }, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 0fd2d28c2c..cb474ca0dc 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -190,7 +190,6 @@ export const ChannelPane = React.memo(function ChannelPane({ timelineScrollRef, composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, - "css-variable", ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index c52be9e2e7..f79a55b980 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -21,9 +21,9 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; -import type { TimelineVirtualizerApi } from "./TimelineMessageList"; import { useAnchoredScroll } from "./useAnchoredScroll"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; +import { useRetainedTimelineWindow } from "./useRetainedTimelineWindow"; export type MessageTimelineHandle = { scrollToBottomOnNextUpdate: () => void; @@ -192,21 +192,6 @@ const MessageTimelineBase = React.forwardRef< const scrollContainerRef = externalScrollRef ?? internalScrollRef; const contentRef = React.useRef(null); const topSentinelRef = React.useRef(null); - const [virtualizerScrollParent, setVirtualizerScrollParent] = - React.useState(null); - const [virtualizerRenderVersion, bumpVirtualizerRenderVersion] = - React.useReducer((version: number) => version + 1, 0); - const [timelineVirtualizerApi, setTimelineVirtualizerApi] = - React.useState(null); - const useTimelineVirtualizer = true; - const activeScrollContainerRef = React.useMemo( - () => ({ - get current() { - return virtualizerScrollParent ?? scrollContainerRef.current; - }, - }), - [scrollContainerRef, virtualizerScrollParent], - ); // Gate the heavy timeline render (each row runs a synchronous // react-markdown parse) behind React concurrency. `useDeferredValue` lets the @@ -246,21 +231,22 @@ const MessageTimelineBase = React.forwardRef< // painted at a stale offset until the user's next scroll event forces layout. const scrollContainerDomKey = channelId ?? "none"; - React.useLayoutEffect(() => { - // Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node. - void scrollContainerDomKey; - if (!useTimelineVirtualizer) { - setVirtualizerScrollParent(scrollContainerRef.current); - } - setTimelineVirtualizerApi(null); - }, [scrollContainerRef, scrollContainerDomKey]); - const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, isLoading: isLoading || isDeferredSnapshotStale, liveCount: messages.length, }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; + const retainedWindow = useRetainedTimelineWindow({ + channelId, + focusMessageId: targetMessageId ?? searchActiveMessageId, + messages: deferredMessages, + scrollContainerRef, + }); + const retainedMessages = deferredMessages.slice( + retainedWindow.start, + retainedWindow.end, + ); const { highlightedMessageId, @@ -270,19 +256,15 @@ const MessageTimelineBase = React.forwardRef< scrollToBottom, scrollToBottomOnNextUpdate, scrollToMessage, - onVirtualizerAtBottomStateChange, } = useAnchoredScroll({ channelId, contentRef, isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, - scrollContainerRef: activeScrollContainerRef, + renderVersion: retainedWindow.renderVersion, + scrollContainerRef, targetMessageId, - virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, - virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom, - virtualizerOwnsPrependAnchoring: useTimelineVirtualizer, - virtualizerRenderVersion, }); const timelineIntroSurface = selectTimelineIntroSurface({ @@ -300,8 +282,7 @@ const MessageTimelineBase = React.forwardRef< ? directMessageIntro : null; const activeChannelIntro = showChannelIntro ? channelIntro : null; - const showIntro = - activeDirectMessageIntro !== null || activeChannelIntro !== null; + const showIntro = showDirectMessageIntro || showChannelIntro; const showGenericEmpty = timelineBodySurface === "empty" && !showIntro; const showMessageList = timelineBodySurface === "list"; @@ -317,9 +298,10 @@ const MessageTimelineBase = React.forwardRef< // `scrollToMessage` always finds the target row. No virtualizer convergence. const jumpToMessage = React.useCallback( (messageId: string, options?: { behavior?: ScrollBehavior }) => { + if (!retainedWindow.ensureMessage(messageId)) return false; return scrollToMessage(messageId, { highlight: true, ...options }); }, - [scrollToMessage], + [retainedWindow.ensureMessage, scrollToMessage], ); // The unread pill is a transient, per-open affordance: dismiss it once the @@ -380,204 +362,39 @@ const MessageTimelineBase = React.forwardRef< } }, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]); - // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it. + // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits. React.useEffect(() => { const target = pendingSearchTargetRef.current; if (!target || showTimelineSkeleton) return; - if ( - useTimelineVirtualizer && - !activeScrollContainerRef.current?.querySelector( - `[data-message-id="${CSS.escape(target)}"]`, - ) - ) { - // Phase 1: ask the virtualizer to realize the match's index. The retry effect - // runs again on range change and the DOM-visible path does the actual - // center + highlight once the row exists. - void jumpToMessage(target, { behavior: "auto" }); - return; - } if (jumpToMessage(target, { behavior: "auto" })) { pendingSearchTargetRef.current = null; } }, [ deferredMessages, jumpToMessage, - showTimelineSkeleton, - virtualizerRenderVersion, - ]); - - const loadOlderViaVirtualizer = React.useCallback(() => { - // Indexed find navigation can legitimately land near the current history - // boundary. Do not mistake that programmatic jump for scrollback intent and - // prepend underneath the active match. - if ( - searchActiveMessageId || - !fetchOlder || - showTimelineSkeleton || - !hasOlderMessages - ) { - return; - } - void fetchOlder(); - }, [ - fetchOlder, - hasOlderMessages, - searchActiveMessageId, + retainedWindow.renderVersion, showTimelineSkeleton, ]); useLoadOlderOnScroll({ - fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder, - hasOlderMessages, + fetchOlder, + hasOlderMessages: hasOlderMessages && retainedWindow.includesStart, isLoading: showTimelineSkeleton, - scrollContainerRef: activeScrollContainerRef, + scrollContainerRef, sentinelRef: topSentinelRef, }); + const handleTimelineScroll = React.useCallback(() => { + onScroll(); + retainedWindow.onScroll(); + }, [onScroll, retainedWindow.onScroll]); + const timelineSkeletonRows = useTimelineSkeletonRows({ channelId, isLoading: showTimelineSkeleton, messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages, }); - const virtualizedLeadingContent = React.useMemo( - () => - activeChannelIntro ? ( -
-
- {activeChannelIntro.icon ?? ( - - )} -
-

- #{activeChannelIntro.channelName} -

-

- This is the beginning of the{" "} - - {activeChannelIntro.channelKindLabel} - - . -

- {activeChannelIntro.description ? ( -

- {activeChannelIntro.description} -

- ) : null} - {activeChannelIntro.actions?.length ? ( -
- {activeChannelIntro.actions.map((action) => ( - - ))} -
- ) : null} -
- ) : activeDirectMessageIntro ? ( -
- -

- {activeDirectMessageIntro.displayName} -

-

- This is the beginning of your direct message with{" "} - - {activeDirectMessageIntro.displayName} - - . -

-
- ) : null, - [activeChannelIntro, activeDirectMessageIntro], - ); - - const handleVirtualizerRangeChanged = React.useCallback(() => { - bumpVirtualizerRenderVersion(); - }, []); - - const timelineList = showMessageList ? ( - - ) : null; - return (
@@ -614,205 +431,221 @@ const MessageTimelineBase = React.forwardRef< ) : null}
- {useTimelineVirtualizer && timelineList ? ( -
- {timelineList} -
- ) : ( +
+
+ + {/* Fixed-height slot: an always-mounted height keeps the virtual + spacer's offset stable across the load-older fetch toggle, so + `scrollMargin` doesn't shift mid-fetch and yank the restore. The + visible fetch spinner lives in the absolute overlay above, which + does not occupy inline flow. */} +
+
-
- - {/* Fixed-height slot: an always-mounted height keeps the virtual - spacer's offset stable across the load-older fetch toggle, so - `scrollMargin` doesn't shift mid-fetch and yank the restore. The - visible fetch spinner lives in the absolute overlay above, which - does not occupy inline flow. */} -
- -
- {showTimelineSkeleton ? ( - - ) : null} - {activeDirectMessageIntro ? ( -
- -

+ {showTimelineSkeleton ? ( + + ) : null} + {activeDirectMessageIntro ? ( +

+ +

+ {activeDirectMessageIntro.displayName} +

+

+ This is the beginning of your direct message with{" "} + {activeDirectMessageIntro.displayName} -

-

- This is the beginning of your direct message with{" "} - - {activeDirectMessageIntro.displayName} - - . -

-
- ) : null} - - {activeChannelIntro ? ( + + . +

+
+ ) : null} + + {activeChannelIntro ? ( +
-
- {activeChannelIntro.icon ?? ( - - )} -
-

- #{activeChannelIntro.channelName} -

-

- This is the beginning of the{" "} - - {activeChannelIntro.channelKindLabel} - - . + {activeChannelIntro.icon ?? ( + + )} +

+

+ #{activeChannelIntro.channelName} +

+

+ This is the beginning of the{" "} + + {activeChannelIntro.channelKindLabel} + + . +

+ {activeChannelIntro.description ? ( +

+ {activeChannelIntro.description}

- {activeChannelIntro.description ? ( -

- {activeChannelIntro.description} -

- ) : null} - {activeChannelIntro.actions?.length ? ( -
- {activeChannelIntro.actions.map((action) => { - const hasDescription = Boolean(action.description); - - return ( - - ); - })} -
- ) : null} -
- ) : null} - - {showGenericEmpty ? ( -
-

- {emptyTitle} -

-

- {emptyDescription} -

-
- ) : null} - - {showMessageList ? ( -
- {timelineList} -
- ) : null} -
+ ) : null} + + + ); + })} +
+ ) : null} +
+ ) : null} + + {showGenericEmpty ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null} + + {showMessageList ? ( +
+ +
+ ) : null}
- )} +
{!isAtBottom ? ( diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index dd6923fc53..fdc9679104 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,6 +1,4 @@ import * as React from "react"; -import { VList } from "virtua"; -import type { VListHandle } from "virtua"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; @@ -8,7 +6,6 @@ import { buildTimelineDayGroups, buildTimelineItems, getTimelineItemKey, - type TimelineDayGroup, type TimelineNonDayItem, } from "@/features/messages/lib/timelineItems"; import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; @@ -31,14 +28,6 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; import { UnreadDivider } from "./UnreadDivider"; -export type TimelineVirtualizerApi = { - scrollToBottom: (behavior?: ScrollBehavior) => void; - scrollToMessage: ( - messageId: string, - options?: { behavior?: ScrollBehavior }, - ) => boolean; -}; - type TimelineMessageListProps = { agentPubkeys?: ReadonlySet; channelId?: string | null; @@ -92,15 +81,6 @@ type TimelineMessageListProps = { searchQuery?: string; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; - /** Content rendered as the first virtual row before channel history. */ - leadingContent?: React.ReactNode; - /** The virtualized timeline owns its scroll node when enabled. */ - useVirtualizer?: boolean; - onStartReached?: () => void; - onAtBottomStateChange?: (atBottom: boolean) => void; - onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; - onVirtualizerRangeChanged?: () => void; - onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -134,13 +114,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, - leadingContent, - useVirtualizer = false, - onStartReached, - onAtBottomStateChange, - onVirtualizerApiChange, - onVirtualizerRangeChanged, - onVirtualizerScrollerChange, }: TimelineMessageListProps) { const entries = React.useMemo( () => @@ -282,21 +255,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ], ); - if (useVirtualizer) { - return ( - - ); - } - return (
{dayGroups.map((group) => ( @@ -318,9 +276,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ )} {group.items.map((item) => ( - +
{renderItem(item)} - +
))} ))} @@ -328,279 +290,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); }); -type VirtualizedTimelineItem = - | { kind: "top-spacer" } - | { kind: "leading-content"; content: React.ReactNode } - | { kind: "bottom-spacer" } - | { kind: "day-heading"; group: TimelineDayGroup } - | { kind: "timeline-item"; item: TimelineNonDayItem }; - -function timelineItemMessageId(item: TimelineNonDayItem): string | null { - return item.kind === "message" || item.kind === "system" - ? item.entry.message.id - : null; -} - -function virtualizedItemKey(item: VirtualizedTimelineItem): string { - if (item.kind === "top-spacer") return "top-spacer"; - if (item.kind === "bottom-spacer") return "bottom-spacer"; - if (item.kind === "leading-content") return "leading-content"; - return item.kind === "day-heading" - ? `day-${item.group.key}` - : getTimelineItemKey(item.item); -} - -function buildVirtualizedItems( - dayGroups: readonly TimelineDayGroup[], - leadingContent?: React.ReactNode, -): VirtualizedTimelineItem[] { - return [ - { kind: "top-spacer" as const }, - ...(leadingContent - ? [{ kind: "leading-content" as const, content: leadingContent }] - : []), - ...dayGroups.flatMap((group) => [ - { kind: "day-heading" as const, group }, - ...group.items.map((item) => ({ kind: "timeline-item" as const, item })), - ]), - { kind: "bottom-spacer" as const }, - ]; -} - -type VirtualizedTimelineRowsProps = { - dayGroups: TimelineDayGroup[]; - leadingContent?: React.ReactNode; - onAtBottomStateChange?: (atBottom: boolean) => void; - onStartReached?: () => void; - onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; - onVirtualizerRangeChanged?: () => void; - onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; - renderItem: (item: TimelineNonDayItem) => React.ReactNode; -}; - -function didPrependVirtualizedTimeline( - previousFirstKey: string | null, - firstTimelineKey: string | null, - keys: readonly string[], -): boolean { - return ( - previousFirstKey !== null && - previousFirstKey !== firstTimelineKey && - keys.includes(previousFirstKey) - ); -} - -function VirtualizedTimelineRows({ - dayGroups, - leadingContent, - onAtBottomStateChange, - onStartReached, - onVirtualizerApiChange, - onVirtualizerRangeChanged, - onVirtualizerScrollerChange, - renderItem, -}: VirtualizedTimelineRowsProps) { - const listRef = React.useRef(null); - const hostRef = React.useRef(null); - const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(() => - typeof window === "undefined" ? 1_000 : window.innerHeight, - ); - const hasInitialPositionedRef = React.useRef(false); - const items = React.useMemo( - () => buildVirtualizedItems(dayGroups, leadingContent), - [dayGroups, leadingContent], - ); - const previousFirstTimelineKeyRef = React.useRef(null); - const [prependShiftEpoch, clearPrependShift] = React.useReducer( - (version: number) => version + 1, - 0, - ); - // Virtua's `shift` is a one-render instruction, not a persistent mode. If it - // stays true after a prepend, later measurement changes can keep anchoring - // from the end and leave a stale blank range until the next scroll event. - const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); - const firstTimelineKey = React.useMemo(() => { - const first = items.find((item) => item.kind === "timeline-item"); - return first?.kind === "timeline-item" - ? getTimelineItemKey(first.item) - : null; - }, [items]); - const isPrepend = React.useMemo(() => { - void prependShiftEpoch; - return didPrependVirtualizedTimeline( - previousFirstTimelineKeyRef.current, - firstTimelineKey, - keys, - ); - }, [firstTimelineKey, keys, prependShiftEpoch]); - React.useLayoutEffect(() => { - previousFirstTimelineKeyRef.current = firstTimelineKey; - if (isPrepend) clearPrependShift(); - if (!hasInitialPositionedRef.current && items.length > 0) { - hasInitialPositionedRef.current = true; - listRef.current?.scrollToIndex(items.length - 1, { align: "end" }); - } - }, [firstTimelineKey, isPrepend, items.length]); - - const messageItemIndexById = React.useMemo(() => { - const byId = new Map(); - items.forEach((item, index) => { - if (item.kind !== "timeline-item") return; - const messageId = timelineItemMessageId(item.item); - if (messageId) byId.set(messageId, index); - }); - return byId; - }, [items]); - - React.useLayoutEffect(() => { - const scroller = hostRef.current?.firstElementChild; - const element = scroller instanceof HTMLDivElement ? scroller : null; - if (element) { - element.dataset.buzzConversationScroll = "true"; - element.dataset.testid = "message-timeline"; - } - onVirtualizerScrollerChange?.(element); - return () => onVirtualizerScrollerChange?.(null); - }, [onVirtualizerScrollerChange]); - - React.useLayoutEffect(() => { - if (!onVirtualizerApiChange) return; - const api: TimelineVirtualizerApi = { - scrollToBottom() { - if (items.length > 0) { - listRef.current?.scrollToIndex(items.length - 1, { align: "end" }); - } - }, - scrollToMessage(messageId) { - const index = messageItemIndexById.get(messageId); - if (index === undefined) return false; - listRef.current?.scrollToIndex(index, { align: "center" }); - return true; - }, - }; - onVirtualizerApiChange(api); - return () => onVirtualizerApiChange(null); - }, [items.length, messageItemIndexById, onVirtualizerApiChange]); - - React.useLayoutEffect(() => { - const host = hostRef.current; - if (!host) return; - const updateBufferSize = () => { - // Keep one complete viewport mounted on either side so variable-height - // rows can settle before they enter view without overworking measurement. - setOffscreenBufferSize(host.clientHeight); - }; - updateBufferSize(); - const resizeObserver = new ResizeObserver(updateBufferSize); - resizeObserver.observe(host); - return () => resizeObserver.disconnect(); - }, []); - - const handleScroll = React.useCallback( - (offset: number) => { - const list = listRef.current; - if (!list) return; - onVirtualizerRangeChanged?.(); - onAtBottomStateChange?.( - list.scrollSize - list.viewportSize - offset <= 32, - ); - if (offset <= 200) onStartReached?.(); - }, - [onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged], - ); - - return ( -
- - {(item) => { - if (item.kind === "top-spacer") { - return ( -
- ); - } - if (item.kind === "bottom-spacer") { - return ( -
- ); - } - if (item.kind === "leading-content") { - return
{item.content}
; - } - if (item.kind === "day-heading") { - const { group } = item; - return ( -
- {group.headingTimestamp === null ? null : ( - - )} -
- ); - } - return ( - - {renderItem(item.item)} - - ); - }} - -
- ); -} - -function TimelineRowShell({ - children, - item, - useContentVisibility = true, -}: { - children: React.ReactNode; - item: TimelineNonDayItem; - useContentVisibility?: boolean; -}) { - return ( -
- {children} -
- ); -} - function SystemRow({ currentPubkey, entry, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2abe471c0f..bf76b3954d 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -46,19 +46,11 @@ type UseAnchoredScrollOptions = { * arrivals and to seed/refresh the anchor pre-render. */ messages: Array<{ id: string }>; + /** Bumps after a retained-window transaction so an off-window target can retry. */ + renderVersion?: number; /** When set, scroll to and highlight this message on mount and on change. */ targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; - virtualScrollToMessage?: ( - messageId: string, - options?: { behavior?: ScrollBehavior }, - ) => boolean; - /** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */ - virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; - /** When active, the virtualizer owns prepend compensation and bottom-state synchronization. */ - virtualizerOwnsPrependAnchoring?: boolean; - /** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */ - virtualizerRenderVersion?: number; }; type UseAnchoredScrollResult = { @@ -82,8 +74,6 @@ type UseAnchoredScrollResult = { messageId: string, options?: { highlight?: boolean; behavior?: ScrollBehavior }, ) => boolean; - /** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */ - onVirtualizerAtBottomStateChange: (atBottom: boolean) => void; }; function isAtBottomNow( @@ -159,13 +149,10 @@ export function useAnchoredScroll({ channelId, isLoading, messages, + renderVersion = 0, targetMessageId = null, onTargetReached, - virtualScrollToMessage, - virtualScrollToBottom, - virtualizerOwnsPrependAnchoring = false, - virtualizerRenderVersion = 0, }: UseAnchoredScrollOptions): UseAnchoredScrollResult { // Anchor lives in a ref because it must survive renders and is updated // both on scroll (commit-time read) and in the layout effect (post-render @@ -238,19 +225,11 @@ export function useAnchoredScroll({ // every imperative bottom jump so `onScroll` holds the at-bottom anchor // until it can snap to the true floor. settlingRef.current = true; - if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { - virtualScrollToBottom(behavior); - } else { - container.scrollTo({ top: container.scrollHeight, behavior }); - } + container.scrollTo({ top: container.scrollHeight, behavior }); setIsAtBottom(true); setNewMessageCount(0); }, - [ - scrollContainerRef, - virtualScrollToBottom, - virtualizerOwnsPrependAnchoring, - ], + [scrollContainerRef], ); // Arm a one-shot: the next append snaps to bottom regardless of where the @@ -260,19 +239,6 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = true; }, []); - const highlightMessage = React.useCallback((messageId: string) => { - if (highlightTimeoutRef.current !== null) { - window.clearTimeout(highlightTimeoutRef.current); - } - setHighlightedMessageId(messageId); - highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedMessageId((current) => - current === messageId ? null : current, - ); - highlightTimeoutRef.current = null; - }, 2_000); - }, []); - const scrollToMessageImperative = React.useCallback( ( messageId: string, @@ -283,44 +249,6 @@ export function useAnchoredScroll({ const el = container.querySelector( `[data-message-id="${messageId}"]`, ); - if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) { - if (el) { - const rect = el.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const isInViewport = - rect.top >= containerRect.top && - rect.bottom <= containerRect.bottom; - if (!isInViewport) { - if (!virtualScrollToMessage(messageId, { behavior: "auto" })) { - return false; - } - anchorRef.current = { kind: "message", messageId, topOffset: 0 }; - setIsAtBottom(false); - return false; - } - const centeredTop = (container.clientHeight - rect.height) / 2; - container.scrollTo({ - top: Math.max( - 0, - container.scrollTop + - (rect.top - containerRect.top) - - centeredTop, - ), - behavior: options.behavior ?? "auto", - }); - } else if ( - !virtualScrollToMessage(messageId, { - behavior: options.behavior ?? "auto", - }) - ) { - return false; - } - anchorRef.current = { kind: "message", messageId, topOffset: 0 }; - setIsAtBottom(false); - if (el && options.highlight) highlightMessage(messageId); - return el !== null; - } - if (!el) return false; const rect = el.getBoundingClientRect(); @@ -354,15 +282,21 @@ export function useAnchoredScroll({ }; setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX); - if (options.highlight) highlightMessage(messageId); + if (options.highlight) { + if (highlightTimeoutRef.current !== null) { + window.clearTimeout(highlightTimeoutRef.current); + } + setHighlightedMessageId(messageId); + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + highlightTimeoutRef.current = null; + }, 2_000); + } return true; }, - [ - highlightMessage, - scrollContainerRef, - virtualizerOwnsPrependAnchoring, - virtualScrollToMessage, - ], + [scrollContainerRef], ); // Scroll handler: recompute anchor + bottom state from the current @@ -371,9 +305,6 @@ export function useAnchoredScroll({ const onScroll = React.useCallback(() => { const container = scrollContainerRef.current; if (!container) return; - // Virtua owns anchoring and reports bottom state separately. Avoid the - // fallback's O(N) DOM walk on every compositor-driven scroll event. - if (virtualizerOwnsPrependAnchoring) return; // Row measurement can grow `scrollHeight` after a bottom pin and emit scroll // events while `scrollTop` holds at the old floor — opening a transient gap // above the true bottom. `computeAnchor` would read that as a deliberate @@ -383,9 +314,6 @@ export function useAnchoredScroll({ if (settleProgrammaticBottomPin(container)) { settlingRef.current = false; } else { - if (virtualizerOwnsPrependAnchoring) { - settlingRef.current = false; - } return; } } @@ -395,7 +323,7 @@ export function useAnchoredScroll({ if (atBottom) { setNewMessageCount(0); } - }, [scrollContainerRef, virtualizerOwnsPrependAnchoring]); + }, [scrollContainerRef]); // --------------------------------------------------------------------------- // Anchor restoration: after every render, stick to the bottom if the user is @@ -471,11 +399,7 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = false; anchorRef.current = { kind: "at-bottom" }; settlingRef.current = true; - if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { - virtualScrollToBottom("auto"); - } else { - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); - } + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); setIsAtBottom(true); setNewMessageCount(0); prevLastMessageIdRef.current = lastMessage?.id; @@ -486,13 +410,10 @@ export function useAnchoredScroll({ } if (anchor.kind === "at-bottom") { - // Stick to bottom across the append. In virtualized mode, the - // virtualizer owns this write; do not slam its scroll element directly. - if (!virtualizerOwnsPrependAnchoring) { - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); - } + // Stick to bottom across the append. + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); if (newLatestArrived) setNewMessageCount(0); - } else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) { + } else if (messagesArrived > 0) { // Anchored mid-history. An older-history prepend grows the content above // the reading row; the browser's native scroll anchoring does NOT correct // this at the top edge (no anchor node above the viewport when scrollTop @@ -531,8 +452,6 @@ export function useAnchoredScroll({ scrollToBottomImperative, scrollToMessageImperative, targetMessageId, - virtualScrollToBottom, - virtualizerOwnsPrependAnchoring, ]); // --------------------------------------------------------------------------- @@ -550,21 +469,13 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if ( - anchorRef.current.kind === "at-bottom" && - !virtualizerOwnsPrependAnchoring - ) { + if (anchorRef.current.kind === "at-bottom") { container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); } }); observer.observe(content); return () => observer.disconnect(); - }, [ - channelId, - contentRef, - scrollContainerRef, - virtualizerOwnsPrependAnchoring, - ]); + }, [channelId, contentRef, scrollContainerRef]); // --------------------------------------------------------------------------- // Target message handling (deep link, jump-to-reply, etc.). Distinct from @@ -578,7 +489,7 @@ export function useAnchoredScroll({ // *without* marking the target handled until its row actually exists — each // subsequent message commit re-runs the effect and retries the centering. // --------------------------------------------------------------------------- - // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits. + // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `renderVersion` are intentional triggers, not reads — the effect reads the DOM, and retries whenever data or the retained row window commits. React.useEffect(() => { if (!targetMessageId) { handledTargetIdRef.current = null; @@ -587,19 +498,11 @@ export function useAnchoredScroll({ if (handledTargetIdRef.current === targetMessageId || isLoading) return; if (!hasInitializedRef.current) return; // initial-mount path will handle. - void virtualizerRenderVersion; const container = scrollContainerRef.current; if (!container) return; const el = container.querySelector( `[data-message-id="${targetMessageId}"]`, ); - if (!el && virtualizerOwnsPrependAnchoring) { - if (scrollToMessageImperative(targetMessageId, { highlight: true })) { - handledTargetIdRef.current = targetMessageId; - onTargetReached?.(targetMessageId); - } - return; - } if (!el) { // Row not in the DOM yet. A cold deep-link target is fetched by id and // spliced into `messages` a render or two later; this effect re-runs on @@ -613,11 +516,10 @@ export function useAnchoredScroll({ isLoading, messages, onTargetReached, + renderVersion, scrollContainerRef, scrollToMessageImperative, targetMessageId, - virtualizerOwnsPrependAnchoring, - virtualizerRenderVersion, ]); React.useEffect(() => { @@ -628,18 +530,6 @@ export function useAnchoredScroll({ }; }, []); - const onVirtualizerAtBottomStateChange = React.useCallback( - (atBottom: boolean) => { - if (!virtualizerOwnsPrependAnchoring) return; - if (atBottom) { - anchorRef.current = { kind: "at-bottom" }; - setNewMessageCount(0); - } - setIsAtBottom(atBottom); - }, - [virtualizerOwnsPrependAnchoring], - ); - return { onScroll, isAtBottom, @@ -648,6 +538,5 @@ export function useAnchoredScroll({ scrollToBottom: scrollToBottomImperative, scrollToBottomOnNextUpdate, scrollToMessage: scrollToMessageImperative, - onVirtualizerAtBottomStateChange, }; } diff --git a/desktop/src/features/messages/ui/useRetainedTimelineWindow.test.mjs b/desktop/src/features/messages/ui/useRetainedTimelineWindow.test.mjs new file mode 100644 index 0000000000..030fbdf126 --- /dev/null +++ b/desktop/src/features/messages/ui/useRetainedTimelineWindow.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + retainedWindowBounds, + RETAINED_TIMELINE_WINDOW_SIZE, +} from "./useRetainedTimelineWindow.ts"; + +const messages = (start, count) => + Array.from({ length: count }, (_, index) => ({ + id: `message-${start + index}`, + })); +const range = (values = {}) => ({ + channelId: "channel", + firstId: "message-350", + followLatest: false, + lastId: "message-599", + sourceFirstId: "message-0", + sourceLastId: "message-599", + ...values, +}); + +test("retained timeline mounts no more than 250 real messages", () => { + const all = messages(0, 600); + assert.deepEqual(retainedWindowBounds(all, range()), { + start: 350, + end: 600, + }); + assert.equal(600 - 350, RETAINED_TIMELINE_WINDOW_SIZE); +}); + +test("new arrivals stay outside the retained window while reading history", () => { + const all = messages(0, 601); + assert.deepEqual(retainedWindowBounds(all, range()), { + start: 350, + end: 600, + }); +}); + +test("the latest window follows ordinary appends while pinned to bottom", () => { + const all = messages(0, 601); + assert.deepEqual(retainedWindowBounds(all, range({ followLatest: true })), { + start: 351, + end: 601, + }); +}); + +test("history prepends retain the same semantic message boundaries", () => { + const all = messages(-50, 650); + assert.deepEqual(retainedWindowBounds(all, range({ followLatest: true })), { + start: 400, + end: 650, + }); +}); diff --git a/desktop/src/features/messages/ui/useRetainedTimelineWindow.ts b/desktop/src/features/messages/ui/useRetainedTimelineWindow.ts new file mode 100644 index 0000000000..2aa1b1103f --- /dev/null +++ b/desktop/src/features/messages/ui/useRetainedTimelineWindow.ts @@ -0,0 +1,260 @@ +import * as React from "react"; + +export const RETAINED_TIMELINE_WINDOW_SIZE = 250; +export const RETAINED_TIMELINE_SHIFT_THRESHOLD = 50; + +type MessageLike = { id: string }; +export type RetainedTimelineWindowRange = { + channelId: string | null; + firstId: string | null; + followLatest: boolean; + lastId: string | null; + sourceFirstId: string | null; + sourceLastId: string | null; +}; +type PendingAnchor = { messageId: string; topOffset: number }; + +export function retainedWindowBounds( + messages: readonly MessageLike[], + range: RetainedTimelineWindowRange, +): { start: number; end: number } { + const count = messages.length; + if (count <= RETAINED_TIMELINE_WINDOW_SIZE) return { start: 0, end: count }; + + const latestStart = count - RETAINED_TIMELINE_WINDOW_SIZE; + const prepended = + range.sourceFirstId !== (messages[0]?.id ?? null) && + range.sourceLastId === (messages[count - 1]?.id ?? null) && + range.sourceFirstId !== null; + if (range.followLatest && !prepended) { + return { start: latestStart, end: count }; + } + + const firstIndex = range.firstId + ? messages.findIndex((message) => message.id === range.firstId) + : -1; + const lastIndex = range.lastId + ? messages.findIndex((message) => message.id === range.lastId) + : -1; + if (firstIndex >= 0 && lastIndex >= firstIndex) { + return { + start: firstIndex, + end: Math.min(count, firstIndex + RETAINED_TIMELINE_WINDOW_SIZE), + }; + } + if (lastIndex >= 0) { + return { + start: Math.max(0, lastIndex - RETAINED_TIMELINE_WINDOW_SIZE + 1), + end: lastIndex + 1, + }; + } + + // A replaced/deleted boundary must never strand the reader in an empty + // window. Falling back to the newest retained page is the least surprising + // recovery and matches channel-open behavior. + return { start: latestStart, end: count }; +} + +function rangeForBounds( + channelId: string | null | undefined, + messages: readonly MessageLike[], + start: number, + end: number, + followLatest = end === messages.length, +): RetainedTimelineWindowRange { + return { + channelId: channelId ?? null, + firstId: messages[start]?.id ?? null, + followLatest, + lastId: messages[end - 1]?.id ?? null, + sourceFirstId: messages[0]?.id ?? null, + sourceLastId: messages[messages.length - 1]?.id ?? null, + }; +} + +function captureVisibleAnchor(container: HTMLElement): PendingAnchor | null { + const containerTop = container.getBoundingClientRect().top; + for (const row of container.querySelectorAll( + "[data-message-id]", + )) { + const rect = row.getBoundingClientRect(); + if (rect.bottom > containerTop) { + const messageId = row.dataset.messageId; + if (messageId) return { messageId, topOffset: rect.top - containerTop }; + } + } + return null; +} + +export function useRetainedTimelineWindow({ + channelId, + focusMessageId, + messages, + scrollContainerRef, +}: { + channelId?: string | null; + focusMessageId?: string | null; + messages: readonly MessageLike[]; + scrollContainerRef: React.RefObject; +}) { + const initialFocusIndex = focusMessageId + ? messages.findIndex((message) => message.id === focusMessageId) + : -1; + const initialStart = + initialFocusIndex >= 0 + ? Math.max( + 0, + Math.min( + Math.max(0, messages.length - RETAINED_TIMELINE_WINDOW_SIZE), + initialFocusIndex - Math.floor(RETAINED_TIMELINE_WINDOW_SIZE / 2), + ), + ) + : Math.max(0, messages.length - RETAINED_TIMELINE_WINDOW_SIZE); + const initialEnd = Math.min( + messages.length, + initialStart + RETAINED_TIMELINE_WINDOW_SIZE, + ); + const [range, setRange] = React.useState(() => + rangeForBounds(channelId, messages, initialStart, initialEnd), + ); + const pendingAnchorRef = React.useRef(null); + const [renderVersion, bumpRenderVersion] = React.useReducer( + (version: number) => version + 1, + 0, + ); + + const effectiveRange = + range.channelId === (channelId ?? null) + ? range + : rangeForBounds( + channelId, + messages, + Math.max(0, messages.length - RETAINED_TIMELINE_WINDOW_SIZE), + messages.length, + ); + const { start, end } = retainedWindowBounds(messages, effectiveRange); + + React.useEffect(() => { + if (range.channelId !== (channelId ?? null)) setRange(effectiveRange); + }, [channelId, effectiveRange, range.channelId]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: start/end are the transaction commit signal; the effect intentionally restores after the retained range changes. + React.useLayoutEffect(() => { + const anchor = pendingAnchorRef.current; + if (!anchor) return; + pendingAnchorRef.current = null; + const container = scrollContainerRef.current; + const row = container?.querySelector( + `[data-message-id="${CSS.escape(anchor.messageId)}"]`, + ); + if (!container || !row) return; + const currentOffset = + row.getBoundingClientRect().top - container.getBoundingClientRect().top; + const drift = currentOffset - anchor.topOffset; + if (Math.abs(drift) > 0.5) container.scrollBy(0, drift); + }, [end, scrollContainerRef, start]); + + const setBounds = React.useCallback( + (nextStart: number, nextEnd: number, preserveAnchor: boolean) => { + const container = scrollContainerRef.current; + if (preserveAnchor && container) { + pendingAnchorRef.current = captureVisibleAnchor(container); + } + setRange( + rangeForBounds( + channelId, + messages, + nextStart, + nextEnd, + nextEnd === messages.length && !preserveAnchor, + ), + ); + bumpRenderVersion(); + }, + [channelId, messages, scrollContainerRef], + ); + + const onScroll = React.useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + const atPhysicalBottom = + container.scrollHeight - container.scrollTop - container.clientHeight <= + 32; + if (effectiveRange.followLatest !== atPhysicalBottom) { + setRange( + rangeForBounds(channelId, messages, start, end, atPhysicalBottom), + ); + } + if (messages.length <= RETAINED_TIMELINE_WINDOW_SIZE) return; + const anchor = captureVisibleAnchor(container); + if (!anchor) return; + const visibleIndex = messages.findIndex( + (message) => message.id === anchor.messageId, + ); + if (visibleIndex < 0) return; + + if (start > 0 && visibleIndex - start < RETAINED_TIMELINE_SHIFT_THRESHOLD) { + const nextStart = Math.max( + 0, + visibleIndex - Math.floor(RETAINED_TIMELINE_WINDOW_SIZE / 2), + ); + setBounds( + nextStart, + Math.min(messages.length, nextStart + RETAINED_TIMELINE_WINDOW_SIZE), + true, + ); + } else if ( + end < messages.length && + end - visibleIndex <= RETAINED_TIMELINE_SHIFT_THRESHOLD + ) { + const nextEnd = Math.min( + messages.length, + visibleIndex + Math.floor(RETAINED_TIMELINE_WINDOW_SIZE / 2), + ); + setBounds( + Math.max(0, nextEnd - RETAINED_TIMELINE_WINDOW_SIZE), + nextEnd, + true, + ); + } + }, [ + channelId, + effectiveRange.followLatest, + end, + messages, + scrollContainerRef, + setBounds, + start, + ]); + + const ensureMessage = React.useCallback( + (messageId: string): boolean => { + const index = messages.findIndex((message) => message.id === messageId); + if (index < 0) return false; + if (index >= start && index < end) return true; + const nextStart = Math.max( + 0, + Math.min( + messages.length - RETAINED_TIMELINE_WINDOW_SIZE, + index - Math.floor(RETAINED_TIMELINE_WINDOW_SIZE / 2), + ), + ); + setBounds( + nextStart, + Math.min(messages.length, nextStart + RETAINED_TIMELINE_WINDOW_SIZE), + false, + ); + return false; + }, + [end, messages, setBounds, start], + ); + + return { + end, + ensureMessage, + includesStart: start === 0, + onScroll, + renderVersion, + start, + }; +} diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index dd7593d094..0975019bda 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -367,10 +367,10 @@ test("thread-heavy history keeps a bounded mounted window", async ({ const mounted = timeline.locator("[data-message-id]"); await expect(mounted).not.toHaveCount(0); const mountedCount = await mounted.count(); - // Two viewports of real overscan intentionally mounts more than the old - // sub-30 window, while still evicting a substantial part of this 120-row - // history after every row has been visited. - expect(mountedCount).toBeLessThan(80); + // Zulip-style retention keeps at most 250 real message rows mounted and only + // swaps that coarse window near a 50-row edge. Thread summaries must not pin + // rows outside the retained page. + expect(mountedCount).toBeLessThanOrEqual(250); }); test("offscreen rich-row resize preserves the viewport-center anchor", async ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38630258f8..b678ba734b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,9 +183,6 @@ importers: upng-js: specifier: ^2.1.0 version: 2.1.0 - virtua: - specifier: 0.49.2 - version: 0.49.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) yaml: specifier: ^2.8.3 version: 2.9.0 @@ -3038,26 +3035,6 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - virtua@0.49.2: - resolution: {integrity: sha512-aEp3+6cmIRjHUlQnWdgGXYMYtrIG26QnN9jJDZEE5LRhvo1Z9HzoJwLDgyVULUPWcSdCnZAroQm7raXJyTG0AA==} - peerDependencies: - react: '>=16.14.0' - react-dom: '>=16.14.0' - solid-js: '>=1.0' - svelte: '>=5.0' - vue: '>=3.2' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vue: - optional: true - vite@8.0.14: resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5987,11 +5964,6 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.49.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - optionalDependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - vite@8.0.14(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 From d3df9a61c8c96bf40aadd0349db21531ea90ae29 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Fri, 10 Jul 2026 09:31:55 -0400 Subject: [PATCH 11/53] Restore smooth virtualized timeline scrolling Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- desktop/package.json | 1 + .../src/features/channels/ui/ChannelPane.tsx | 1 + .../features/messages/ui/MessageComposer.tsx | 2 +- .../features/messages/ui/MessageTimeline.tsx | 678 ++++++++++++------ .../messages/ui/TimelineMessageList.tsx | 323 ++++++++- .../features/messages/ui/useAnchoredScroll.ts | 165 ++++- .../ui/useBufferedTimelineMessages.test.mjs | 52 ++ .../ui/useBufferedTimelineMessages.ts | 90 +++ .../ui/useRetainedTimelineWindow.test.mjs | 54 -- .../messages/ui/useRetainedTimelineWindow.ts | 260 ------- desktop/tests/e2e/virtualization.spec.ts | 47 +- pnpm-lock.yaml | 28 + 12 files changed, 1136 insertions(+), 565 deletions(-) create mode 100644 desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs create mode 100644 desktop/src/features/messages/ui/useBufferedTimelineMessages.ts delete mode 100644 desktop/src/features/messages/ui/useRetainedTimelineWindow.test.mjs delete mode 100644 desktop/src/features/messages/ui/useRetainedTimelineWindow.ts diff --git a/desktop/package.json b/desktop/package.json index ed17b84e33..e2bb06d475 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -77,6 +77,7 @@ "tailwind-merge": "^3.5.0", "tiptap-markdown": "^0.9.0", "upng-js": "^2.1.0", + "virtua": "0.49.2", "yaml": "^2.8.3", "zod": "^4.4.3" }, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index cb474ca0dc..0fd2d28c2c 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -190,6 +190,7 @@ export const ChannelPane = React.memo(function ChannelPane({ timelineScrollRef, composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, + "css-variable", ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9a52a447c9..8a72e23c7b 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -902,7 +902,7 @@ function MessageComposerImpl({ >