diff --git a/desktop/src/features/messages/lib/timelineImagePreload.test.mjs b/desktop/src/features/messages/lib/timelineImagePreload.test.mjs
new file mode 100644
index 0000000000..a486f726f3
--- /dev/null
+++ b/desktop/src/features/messages/lib/timelineImagePreload.test.mjs
@@ -0,0 +1,101 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { timelineImageUrls } from "./timelineImagePreload.ts";
+
+function message(over = {}) {
+ return {
+ id: "m1",
+ createdAt: 0,
+ author: "a",
+ time: "now",
+ body: "",
+ depth: 0,
+ ...over,
+ };
+}
+
+test("timelineImageUrls finds every image a timeline row can render", () => {
+ const urls = timelineImageUrls(
+ message({
+ avatarUrl: "https://example.com/avatar.jpg",
+ body: [
+ "",
+ '',
+ ].join("\n"),
+ tags: [
+ ["imeta", "url https://example.com/three.jpg", "m image/jpeg"],
+ [
+ "imeta",
+ "url https://example.com/movie.mp4",
+ "m video/mp4",
+ "image https://example.com/poster.jpg",
+ "thumb https://example.com/thumb.jpg",
+ ],
+ ["emoji", "party", "https://example.com/party.png"],
+ ],
+ reactions: [
+ {
+ emoji: ":party:",
+ emojiUrl: "https://example.com/reaction.png",
+ count: 1,
+ users: [],
+ },
+ ],
+ }),
+ );
+
+ assert.deepEqual(
+ new Set(urls),
+ new Set([
+ "https://example.com/avatar.jpg",
+ "https://example.com/one.png",
+ "https://example.com/two.webp",
+ "https://example.com/three.jpg",
+ "https://example.com/poster.jpg",
+ "https://example.com/thumb.jpg",
+ "https://example.com/party.png",
+ "https://example.com/reaction.png",
+ ]),
+ );
+ assert.ok(!urls.includes("https://example.com/movie.mp4"));
+});
+
+test("timelineImageUrls deduplicates image URLs", () => {
+ const url = "https://example.com/same.png";
+ assert.deepEqual(
+ timelineImageUrls(
+ message({
+ avatarUrl: url,
+ body: ``,
+ tags: [["imeta", `url ${url}`, "m image/png"]],
+ }),
+ ),
+ [url],
+ );
+});
+
+test("preloadTimelineImages requests URLs once and keeps requests alive", async () => {
+ const { preloadTimelineImages } = await import("./timelineImagePreload.ts");
+ const previousImage = globalThis.Image;
+ const requested = [];
+ class FakeImage extends EventTarget {
+ set src(url) {
+ requested.push(url);
+ }
+ }
+ globalThis.Image = FakeImage;
+ try {
+ const state = { activeImages: new Set(), requestedUrls: new Set() };
+ const messages = [message({ body: "" })];
+ preloadTimelineImages(messages, state);
+ preloadTimelineImages(messages, state);
+
+ assert.deepEqual(requested, ["https://example.com/one.png"]);
+ assert.equal(state.activeImages.size, 1);
+ state.activeImages.values().next().value.dispatchEvent(new Event("load"));
+ assert.equal(state.activeImages.size, 0);
+ } finally {
+ globalThis.Image = previousImage;
+ }
+});
diff --git a/desktop/src/features/messages/lib/timelineImagePreload.ts b/desktop/src/features/messages/lib/timelineImagePreload.ts
new file mode 100644
index 0000000000..af03b5e591
--- /dev/null
+++ b/desktop/src/features/messages/lib/timelineImagePreload.ts
@@ -0,0 +1,63 @@
+import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
+import type { TimelineMessage } from "../types";
+import { parseImetaTags } from "./parseImeta";
+
+const MARKDOWN_IMAGE_RE = /!\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
+
+/**
+ * Return every image URL a mounted timeline row can request. Keeping this
+ * projection independent of row rendering lets a virtualized timeline warm the
+ * browser cache before the row enters Virtua's mounted range.
+ */
+export function timelineImageUrls(message: TimelineMessage): string[] {
+ const urls = new Set
();
+ const add = (url: string | null | undefined) => {
+ if (url) urls.add(rewriteRelayUrl(url));
+ };
+
+ add(message.avatarUrl);
+
+ for (const match of message.body.matchAll(MARKDOWN_IMAGE_RE)) {
+ add(match[1]);
+ }
+
+ if (message.tags) {
+ for (const entry of parseImetaTags(message.tags).values()) {
+ if (entry.m?.startsWith("image/")) add(entry.url);
+ // Video poster frames are images too, and otherwise arrive only when the
+ // virtualized row mounts its player.
+ add(entry.image);
+ add(entry.thumb);
+ }
+ for (const tag of message.tags) {
+ if (tag[0] === "emoji") add(tag[2]);
+ }
+ }
+
+ for (const reaction of message.reactions ?? []) add(reaction.emojiUrl);
+ return [...urls];
+}
+
+export type TimelineImagePreloadState = {
+ activeImages: Set;
+ requestedUrls: Set;
+};
+
+/** Start all image requests now, independently of Virtua row mounting. */
+export function preloadTimelineImages(
+ messages: readonly TimelineMessage[],
+ state: TimelineImagePreloadState,
+): void {
+ for (const message of messages) {
+ for (const url of timelineImageUrls(message)) {
+ if (state.requestedUrls.has(url)) continue;
+ state.requestedUrls.add(url);
+ const image = new Image();
+ state.activeImages.add(image);
+ const release = () => state.activeImages.delete(image);
+ image.addEventListener("load", release, { once: true });
+ image.addEventListener("error", release, { once: true });
+ image.src = url;
+ }
+ }
+}
diff --git a/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx
new file mode 100644
index 0000000000..1a1018fbe7
--- /dev/null
+++ b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx
@@ -0,0 +1,60 @@
+import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
+import { UserAvatar } from "@/shared/ui/UserAvatar";
+
+export type DirectMessageIntroParticipant = {
+ avatarUrl: string | null;
+ displayName: string;
+ pubkey: string;
+};
+
+export function DirectMessageIntroAvatarStack({
+ participants,
+}: {
+ participants: DirectMessageIntroParticipant[];
+}) {
+ const { hiddenCount, visibleParticipants } =
+ getDmParticipantPreview(participants);
+ const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
+
+ return (
+
+ {visibleParticipants.map((participant, index) => (
+
0 ? "-ml-5" : ""}
+ data-testid="message-dm-intro-avatar-stack-participant"
+ key={participant.pubkey}
+ style={{
+ zIndex: index + 1,
+ ...(index < stackItemCount - 1 && {
+ mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
+ WebkitMask:
+ "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
+ }),
+ }}
+ >
+
+
+ ))}
+ {hiddenCount > 0 ? (
+
0 ? "-ml-5" : ""}
+ data-testid="message-dm-intro-avatar-stack-more"
+ style={{ zIndex: stackItemCount }}
+ >
+
+ +{hiddenCount}
+
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 11e6190127..39512fa0ae 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -902,7 +902,7 @@ function MessageComposerImpl({
>
void;
@@ -120,12 +126,6 @@ type ChannelIntro = {
* message list. Must be module-level so its identity never changes. */
const EMPTY_MESSAGES: TimelineMessage[] = [];
-type DirectMessageIntroParticipant = {
- avatarUrl: string | null;
- displayName: string;
- pubkey: string;
-};
-
type TimelineSnapshot = {
channelId: string | null;
messages: TimelineMessage[];
@@ -191,6 +191,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 = 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
@@ -215,6 +230,13 @@ const MessageTimelineBase = React.forwardRef<
EMPTY_TIMELINE_SNAPSHOT,
);
const deferredMessages = deferredSnapshot.messages;
+ const imagePreloadStateRef = React.useRef({
+ activeImages: new Set(),
+ requestedUrls: new Set(),
+ });
+ React.useEffect(() => {
+ preloadTimelineImages(messages, imagePreloadStateRef.current);
+ }, [messages]);
const isDeferredSnapshotStale = isDeferredTimelineSnapshotStale({
deferredSnapshot,
liveSnapshot,
@@ -230,12 +252,49 @@ 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 [isSemanticallyAtBottom, setIsSemanticallyAtBottom] =
+ React.useState(true);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes
+ React.useEffect(() => {
+ setIsSemanticallyAtBottom(true);
+ }, [channelId]);
+ // Zulip-style data semantics: once the reader leaves the bottom, keep the
+ // virtualizer's logical tail frozen. Live arrivals accumulate behind the
+ // "new messages" affordance instead of changing Virtua's item model under
+ // the reading position. Prepends still flow through immediately and Virtua's
+ // `shift` transaction preserves the stable keyed row.
+ const bufferedTimeline = useBufferedTimelineMessages({
+ channelId,
+ isAtBottom:
+ isSemanticallyAtBottom ||
+ targetMessageId !== null ||
+ searchActiveMessageId !== null,
+ messages: deferredMessages,
+ });
+ // Hold older-page render commits until the scroller is at rest: WKWebView
+ // can drop scrollTop compensation writes during live trackpad momentum.
+ // Full rationale in useSettleGatedPrependMessages.
+ const { messages: renderedMessages, isHoldingPrepend } =
+ useSettleGatedPrependMessages({
+ channelId,
+ messages: bufferedTimeline.messages,
+ scrollElementRef: activeScrollContainerRef,
+ });
const {
highlightedMessageId,
@@ -245,21 +304,83 @@ const MessageTimelineBase = React.forwardRef<
scrollToBottom,
scrollToBottomOnNextUpdate,
scrollToMessage,
+ onVirtualizerAtBottomStateChange,
} = useAnchoredScroll({
channelId,
contentRef,
isLoading: showTimelineSkeleton,
- messages: deferredMessages,
+ messages: renderedMessages,
onTargetReached,
- scrollContainerRef,
+ scrollContainerRef: activeScrollContainerRef,
targetMessageId,
+ virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
+ virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
+ virtualizerOwnsPrependAnchoring: useTimelineVirtualizer,
+ virtualizerRenderVersion,
});
+ const hasConfirmedVirtualizerBottomRef = React.useRef(false);
+ const bottomConfirmationChannelRef = React.useRef(channelId);
+ if (bottomConfirmationChannelRef.current !== channelId) {
+ bottomConfirmationChannelRef.current = channelId;
+ hasConfirmedVirtualizerBottomRef.current = false;
+ }
+ const suppressNextSemanticBottomRef = React.useRef(false);
+ const semanticAtBottomRef = React.useRef(isSemanticallyAtBottom);
+ semanticAtBottomRef.current = isSemanticallyAtBottom;
+ const semanticBottomRafRef = React.useRef(null);
+ const queueSemanticBottom = React.useCallback((atBottom: boolean) => {
+ semanticAtBottomRef.current = atBottom;
+ if (semanticBottomRafRef.current !== null) {
+ window.cancelAnimationFrame(semanticBottomRafRef.current);
+ }
+ semanticBottomRafRef.current = window.requestAnimationFrame(() => {
+ semanticBottomRafRef.current = null;
+ setIsSemanticallyAtBottom(atBottom);
+ });
+ }, []);
+ React.useEffect(
+ () => () => {
+ if (semanticBottomRafRef.current !== null) {
+ window.cancelAnimationFrame(semanticBottomRafRef.current);
+ }
+ },
+ [],
+ );
+ const handleVirtualizerAtBottomStateChange = React.useCallback(
+ (atBottom: boolean) => {
+ // Virtua can emit an intermediate non-bottom offset while its initial
+ // scroll-to-end is still converging. Do not turn that mount transient
+ // into a semantic dataset freeze: wait until this channel has reached a
+ // confirmed bottom once, then track genuine bottom -> history movement.
+ if (atBottom) {
+ hasConfirmedVirtualizerBottomRef.current = true;
+ onVirtualizerAtBottomStateChange(true);
+ if (suppressNextSemanticBottomRef.current) {
+ // Freezing the tail shortens Virtua's model and can itself make the
+ // current offset report "at bottom". That synthetic transition must
+ // not immediately release the snapshot and oscillate forever.
+ suppressNextSemanticBottomRef.current = false;
+ } else if (!semanticAtBottomRef.current) {
+ queueSemanticBottom(true);
+ }
+ } else if (hasConfirmedVirtualizerBottomRef.current) {
+ onVirtualizerAtBottomStateChange(false);
+ if (semanticAtBottomRef.current) {
+ suppressNextSemanticBottomRef.current = true;
+ queueSemanticBottom(false);
+ }
+ }
+ },
+ [onVirtualizerAtBottomStateChange, queueSemanticBottom],
+ );
+
const timelineIntroSurface = selectTimelineIntroSurface({
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
hasDirectMessageIntro: directMessageIntro !== null,
hasReachedChannelStart:
!isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) &&
+ !isHoldingPrepend &&
(messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)),
isSkeletonVisible: showTimelineSkeleton,
});
@@ -270,16 +391,25 @@ const MessageTimelineBase = React.forwardRef<
? directMessageIntro
: null;
const activeChannelIntro = showChannelIntro ? channelIntro : null;
- const showIntro = showDirectMessageIntro || showChannelIntro;
+ const showIntro =
+ activeDirectMessageIntro !== null || activeChannelIntro !== null;
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
const showMessageList = timelineBodySurface === "list";
+ const prepareForOwnMessage = React.useCallback(() => {
+ // The user's own send is the deliberate Zulip exception: release buffered
+ // output before arming the next-append bottom pin so the sent row can enter
+ // Virtua's model and become the new physical floor.
+ setIsSemanticallyAtBottom(true);
+ scrollToBottomOnNextUpdate();
+ }, [scrollToBottomOnNextUpdate]);
+
React.useImperativeHandle(
ref,
() => ({
- scrollToBottomOnNextUpdate,
+ scrollToBottomOnNextUpdate: prepareForOwnMessage,
}),
- [scrollToBottomOnNextUpdate],
+ [prepareForOwnMessage],
);
// Jump-to-message is purely DOM-based now: all loaded rows are mounted, so
@@ -349,20 +479,64 @@ 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((): boolean => {
+ // 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.
+ // A settle-gate hold means the reader is still parked at the OLD
+ // boundary — don't stack more page fetches behind the held commit.
+ if (
+ searchActiveMessageId ||
+ !fetchOlder ||
+ isFetchingOlder ||
+ isHoldingPrepend ||
+ showTimelineSkeleton ||
+ !hasOlderMessages
+ ) {
+ return false;
+ }
+ void fetchOlder();
+ return true;
+ }, [
+ fetchOlder,
+ hasOlderMessages,
+ isFetchingOlder,
+ isHoldingPrepend,
+ searchActiveMessageId,
+ showTimelineSkeleton,
+ ]);
useLoadOlderOnScroll({
- fetchOlder,
+ fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder,
hasOlderMessages,
isLoading: showTimelineSkeleton,
- scrollContainerRef,
+ scrollContainerRef: activeScrollContainerRef,
sentinelRef: topSentinelRef,
});
@@ -372,6 +546,144 @@ 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) => (
+
+
+ {action.icon}
+
+
+
+ {action.label}
+
+ {action.description ? (
+
+ {action.description}
+
+ ) : null}
+
+
+ ))}
+
+ ) : 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 (
@@ -391,8 +703,10 @@ const MessageTimelineBase = React.forwardRef<
) : null}
{/* `isFetchingOlder` clears on fetch resolve, but rows paint a frame
- later off the deferred snapshot — keep the spinner up until then. */}
+ later (deferred snapshot / settle-gate hold) — keep the spinner up
+ until the page actually renders. */}
{isFetchingOlder ||
+ isHoldingPrepend ||
isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? (
-
-
-
- {/* 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 (
-
-
+ {activeChannelIntro.description}
+
+ ) : null}
+ {activeChannelIntro.actions?.length ? (
+
+ {activeChannelIntro.actions.map((action) => {
+ const hasDescription = Boolean(action.description);
+
+ return (
+
- {action.icon}
-
-
- {action.label}
+ {action.icon}
- {action.description ? (
+
- {action.description}
+ {action.label}
- ) : null}
-
-
- );
- })}
-
- ) : null}
-
- ) : null}
-
- {showGenericEmpty ? (
-
-
- {emptyTitle}
-
-
- {emptyDescription}
-
-
- ) : null}
-
- {showMessageList ? (
-
-
-
- ) : null}
+ {action.description ? (
+
+ {action.description}
+
+ ) : null}
+
+
+ );
+ })}
+
+ ) : null}
+
+ ) : null}
+
+ {showGenericEmpty ? (
+
+
+ {emptyTitle}
+
+
+ {emptyDescription}
+
+
+ ) : null}
+
+ {showMessageList ? (
+
+ {timelineList}
+
+ ) : null}
+
-
+ )}
{!isAtBottom ? (
@@ -631,12 +939,15 @@ const MessageTimelineBase = React.forwardRef<
0
- ? unreadCountLabel(newMessageCount)
- : "Jump to latest"
+ bufferedTimeline.pendingCount > 0
+ ? unreadCountLabel(bufferedTimeline.pendingCount)
+ : newMessageCount > 0
+ ? unreadCountLabel(newMessageCount)
+ : "Jump to latest"
}
onClick={() => {
- scrollToBottom("smooth");
+ setIsSemanticallyAtBottom(true);
+ window.requestAnimationFrame(() => scrollToBottom("auto"));
}}
testId="message-scroll-to-latest"
/>
@@ -648,55 +959,3 @@ const MessageTimelineBase = React.forwardRef<
});
export const MessageTimeline = React.memo(MessageTimelineBase);
-
-function DirectMessageIntroAvatarStack({
- participants,
-}: {
- participants: DirectMessageIntroParticipant[];
-}) {
- const { hiddenCount, visibleParticipants } =
- getDmParticipantPreview(participants);
- const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
-
- return (
-
- {visibleParticipants.map((participant, index) => (
-
0 ? "-ml-5" : ""}
- data-testid="message-dm-intro-avatar-stack-participant"
- key={participant.pubkey}
- style={{
- zIndex: index + 1,
- ...(index < stackItemCount - 1 && {
- mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
- WebkitMask:
- "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
- }),
- }}
- >
-
-
- ))}
- {hiddenCount > 0 ? (
-
0 ? "-ml-5" : ""}
- data-testid="message-dm-intro-avatar-stack-more"
- style={{ zIndex: stackItemCount }}
- >
-
- +{hiddenCount}
-
-
- ) : null}
-
- );
-}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index fdc9679104..0102d9ce1e 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,15 @@ 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?: () => boolean;
+ 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 +134,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchQuery,
threadUnreadCounts,
unfollowThreadById,
+ leadingContent,
+ useVirtualizer = false,
+ onStartReached,
+ onAtBottomStateChange,
+ onVirtualizerApiChange,
+ onVirtualizerRangeChanged,
+ onVirtualizerScrollerChange,
}: TimelineMessageListProps) {
const entries = React.useMemo(
() =>
@@ -255,6 +282,21 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
],
);
+ if (useVirtualizer) {
+ return (
+
+ );
+ }
+
return (
{dayGroups.map((group) => (
@@ -276,13 +318,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
)}
{group.items.map((item) => (
-
+
{renderItem(item)}
-
+
))}
))}
@@ -290,6 +328,482 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
);
});
+type VirtualizedTimelineItem =
+ | {
+ kind: "leading-content";
+ content: React.ReactNode;
+ hasTopClearance: boolean;
+ }
+ | { kind: "bottom-spacer" }
+ | {
+ kind: "timeline-item";
+ item: TimelineNonDayItem;
+ dayHeading: Pick
| null;
+ hasTopClearance: boolean;
+ };
+
+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 === "bottom-spacer") return "bottom-spacer";
+ if (item.kind === "leading-content") return "leading-content";
+ return getTimelineItemKey(item.item);
+}
+
+function buildVirtualizedItems(
+ dayGroups: readonly TimelineDayGroup[],
+ leadingContent?: React.ReactNode,
+): VirtualizedTimelineItem[] {
+ const timelineItems = dayGroups.flatMap((group) =>
+ group.items.map((item, index) => ({
+ kind: "timeline-item" as const,
+ item,
+ dayHeading:
+ index === 0
+ ? {
+ headingTimestamp: group.headingTimestamp,
+ }
+ : null,
+ hasTopClearance: !leadingContent && group === dayGroups[0] && index === 0,
+ })),
+ );
+
+ return [
+ ...(leadingContent
+ ? [
+ {
+ kind: "leading-content" as const,
+ content: leadingContent,
+ hasTopClearance: true,
+ },
+ ]
+ : []),
+ ...timelineItems,
+ { kind: "bottom-spacer" as const },
+ ];
+}
+
+type VirtualizedTimelineRowsProps = {
+ dayGroups: TimelineDayGroup[];
+ leadingContent?: React.ReactNode;
+ onAtBottomStateChange?: (atBottom: boolean) => void;
+ onStartReached?: () => boolean;
+ onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
+ onVirtualizerRangeChanged?: () => void;
+ onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
+ renderItem: (item: TimelineNonDayItem) => React.ReactNode;
+};
+
+function didPrependVirtualizedTimeline(
+ previousKeys: readonly string[],
+ keys: readonly string[],
+): boolean {
+ const prependedCount = keys.length - previousKeys.length;
+ // Virtua shifts its positional size cache by the length delta, so enable
+ // `shift` only when every previous slot is the exact suffix it expects.
+ return (
+ prependedCount > 0 &&
+ previousKeys.every((key, index) => key === keys[index + prependedCount])
+ );
+}
+
+function VirtualizedTimelineRows({
+ dayGroups,
+ leadingContent,
+ onAtBottomStateChange,
+ onStartReached,
+ onVirtualizerApiChange,
+ onVirtualizerRangeChanged,
+ onVirtualizerScrollerChange,
+ renderItem,
+}: VirtualizedTimelineRowsProps) {
+ const listRef = React.useRef(null);
+ const hostRef = React.useRef(null);
+ const itemsLengthRef = React.useRef(0);
+ const messageItemIndexByIdRef = React.useRef>(
+ new Map(),
+ );
+ const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(() =>
+ typeof window === "undefined" ? 1_000 : window.innerHeight,
+ );
+ const initialPositionFrameRef = React.useRef(null);
+ const hasInitialPositionedRef = React.useRef(false);
+ const items = React.useMemo(
+ () => buildVirtualizedItems(dayGroups, leadingContent),
+ [dayGroups, leadingContent],
+ );
+ itemsLengthRef.current = items.length;
+ const previousKeysRef = React.useRef([]);
+ const prependAnchorRef = React.useRef<{
+ messageId: string;
+ top: number;
+ } | null>(null);
+ const prependWatcherFrameRef = React.useRef(null);
+ const cancelUpwardMomentumRef = React.useRef(false);
+ 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 isPrepend = React.useMemo(() => {
+ void prependShiftEpoch;
+ return didPrependVirtualizedTimeline(previousKeysRef.current, keys);
+ }, [keys, prependShiftEpoch]);
+
+ const retirePrependAnchor = React.useCallback(() => {
+ if (prependWatcherFrameRef.current !== null) {
+ cancelAnimationFrame(prependWatcherFrameRef.current);
+ }
+ prependWatcherFrameRef.current = null;
+ prependAnchorRef.current = null;
+ }, []);
+
+ const capturePrependAnchor = React.useCallback(() => {
+ // Keep the pending capture current while the fetch is in flight. Once the
+ // prepend commits and the watcher starts, its baseline is frozen.
+ if (prependWatcherFrameRef.current !== null) return;
+ const scroller = hostRef.current?.firstElementChild;
+ if (!(scroller instanceof HTMLDivElement)) return;
+ const scrollerTop = scroller.getBoundingClientRect().top;
+ const row = Array.from(
+ scroller.querySelectorAll("[data-message-id]"),
+ ).find((candidate) => candidate.getBoundingClientRect().top >= scrollerTop);
+ const messageId = row?.dataset.messageId;
+ if (!row || !messageId) return;
+ prependAnchorRef.current = {
+ messageId,
+ top: row.getBoundingClientRect().top - scrollerTop,
+ };
+ }, []);
+
+ React.useLayoutEffect(() => {
+ if (!isPrepend || !prependAnchorRef.current) return;
+ // Virtua's shift mode correctly absorbs most prepended measurements, but
+ // estimated offsets can misclassify late row growth deep in history. Keep
+ // the semantic row identity as a short-lived, deviation-gated backstop.
+ // This watcher deliberately survives a temporary row unmount and uses only
+ // relative correction so Virtua remains the primary scroll owner.
+ if (prependWatcherFrameRef.current !== null) {
+ cancelAnimationFrame(prependWatcherFrameRef.current);
+ }
+ const anchor = prependAnchorRef.current;
+ const scroller = hostRef.current?.firstElementChild;
+ if (scroller instanceof HTMLDivElement) {
+ const row = Array.from(
+ scroller.querySelectorAll("[data-message-id]"),
+ ).find((candidate) => candidate.dataset.messageId === anchor.messageId);
+ if (row) {
+ const top =
+ row.getBoundingClientRect().top -
+ scroller.getBoundingClientRect().top;
+ const delta = top - anchor.top;
+ // Close the commit-frame displacement before paint. The watcher below
+ // remains responsible for late measurements and temporary unmounts.
+ if (Math.abs(delta) > 4) scroller.scrollBy({ top: delta });
+ }
+ }
+ const deadline = performance.now() + 3_000;
+ let previousScrollTop: number | null = null;
+ let settledFrames = 0;
+
+ const watch = () => {
+ const scroller = hostRef.current?.firstElementChild;
+ if (!(scroller instanceof HTMLDivElement)) {
+ prependWatcherFrameRef.current = null;
+ prependAnchorRef.current = null;
+ return;
+ }
+ const atBottom =
+ scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
+ 32;
+ const row = Array.from(
+ scroller.querySelectorAll("[data-message-id]"),
+ ).find((candidate) => candidate.dataset.messageId === anchor.messageId);
+ const top = row
+ ? row.getBoundingClientRect().top - scroller.getBoundingClientRect().top
+ : null;
+ const scrollTop = scroller.scrollTop;
+ settledFrames =
+ previousScrollTop !== null &&
+ Math.abs(scrollTop - previousScrollTop) < 0.5
+ ? settledFrames + 1
+ : 0;
+ previousScrollTop = scrollTop;
+
+ if (row && top !== null && settledFrames >= 2) {
+ const delta = top - anchor.top;
+ if (Math.abs(delta) > 4) {
+ scroller.scrollBy({ top: delta });
+ settledFrames = 0;
+ previousScrollTop = null;
+ }
+ }
+
+ const retired =
+ performance.now() >= deadline ||
+ atBottom ||
+ (top !== null && top > scroller.clientHeight * 2);
+ if (retired) {
+ retirePrependAnchor();
+ return;
+ }
+ prependWatcherFrameRef.current = requestAnimationFrame(watch);
+ };
+ prependWatcherFrameRef.current = requestAnimationFrame(watch);
+ }, [isPrepend, retirePrependAnchor]);
+
+ React.useEffect(() => () => retirePrependAnchor(), [retirePrependAnchor]);
+
+ React.useLayoutEffect(() => {
+ previousKeysRef.current = keys;
+ if (isPrepend) clearPrependShift();
+ if (!hasInitialPositionedRef.current && items.length > 0) {
+ hasInitialPositionedRef.current = true;
+ const scrollToSettledBottom = () => {
+ listRef.current?.scrollToIndex(items.length - 1, { align: "end" });
+ };
+ scrollToSettledBottom();
+ initialPositionFrameRef.current = requestAnimationFrame(() => {
+ initialPositionFrameRef.current = null;
+ scrollToSettledBottom();
+ });
+ }
+ }, [isPrepend, items.length, keys]);
+
+ React.useEffect(
+ () => () => {
+ if (initialPositionFrameRef.current !== null) {
+ cancelAnimationFrame(initialPositionFrameRef.current);
+ }
+ },
+ [],
+ );
+
+ 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]);
+ messageItemIndexByIdRef.current = messageItemIndexById;
+
+ 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() {
+ const lastIndex = itemsLengthRef.current - 1;
+ if (lastIndex >= 0) {
+ listRef.current?.scrollToIndex(lastIndex, { align: "end" });
+ }
+ },
+ scrollToMessage(messageId) {
+ const index = messageItemIndexByIdRef.current.get(messageId);
+ if (index === undefined) return false;
+ listRef.current?.scrollToIndex(index, { align: "center" });
+ return true;
+ },
+ };
+ onVirtualizerApiChange(api);
+ return () => onVirtualizerApiChange(null);
+ }, [onVirtualizerApiChange]);
+
+ React.useLayoutEffect(() => {
+ const host = hostRef.current;
+ if (!host) return;
+ const updateBufferSize = () => {
+ // Measure rows three viewports ahead of the reader. Virtua deliberately
+ // hides each newly mounted row until its first ResizeObserver result; a
+ // one-viewport lead can be consumed by WebKit trackpad momentum before
+ // that result commits, producing a first-pass-only blank flash. The
+ // measured size is cached, which is why revisiting the same range is
+ // already stable.
+ setOffscreenBufferSize(host.clientHeight * 3);
+ };
+ updateBufferSize();
+ const resizeObserver = new ResizeObserver(updateBufferSize);
+ resizeObserver.observe(host);
+ return () => resizeObserver.disconnect();
+ }, []);
+
+ React.useLayoutEffect(() => {
+ const scroller = hostRef.current?.firstElementChild;
+ if (!(scroller instanceof HTMLDivElement)) return;
+ let releaseTimer: number | null = null;
+ const onWheel = (event: WheelEvent) => {
+ if (event.deltaY >= 0) {
+ cancelUpwardMomentumRef.current = false;
+ if (releaseTimer !== null) window.clearTimeout(releaseTimer);
+ releaseTimer = null;
+ return;
+ }
+ if (!cancelUpwardMomentumRef.current && scroller.scrollTop > 200) return;
+ event.preventDefault();
+ cancelUpwardMomentumRef.current = true;
+ // Momentum wheel events arrive as one burst. Suppress only that burst;
+ // after a short quiet period, a fresh gesture is admitted normally.
+ if (releaseTimer !== null) window.clearTimeout(releaseTimer);
+ releaseTimer = window.setTimeout(() => {
+ cancelUpwardMomentumRef.current = false;
+ releaseTimer = null;
+ }, 80);
+ };
+ scroller.addEventListener("wheel", onWheel, { passive: false });
+ return () => {
+ scroller.removeEventListener("wheel", onWheel);
+ if (releaseTimer !== null) window.clearTimeout(releaseTimer);
+ };
+ }, []);
+
+ const handleScroll = React.useCallback(
+ (offset: number) => {
+ const list = listRef.current;
+ if (!list) return;
+ onVirtualizerRangeChanged?.();
+ onAtBottomStateChange?.(
+ list.scrollSize - list.viewportSize - offset <= 32,
+ );
+ if (offset > 200 && prependWatcherFrameRef.current !== null) {
+ // The reader has deliberately left the page boundary. A watcher from
+ // the previous prepend must not carry its old row baseline into the
+ // next trip to the edge and pull that later viewport back down.
+ retirePrependAnchor();
+ }
+ if (prependAnchorRef.current !== null || offset <= 200) {
+ capturePrependAnchor();
+ }
+ if (offset <= 200) {
+ onStartReached?.();
+ // Reaching the loaded-history boundary ends this upward gesture even
+ // when a page request is already in flight. Suppress only its inertial
+ // tail; the wheel listener admits downward input immediately and a new
+ // upward gesture after the short quiet-period release.
+ cancelUpwardMomentumRef.current = true;
+ }
+ },
+ [
+ capturePrependAnchor,
+ onAtBottomStateChange,
+ onStartReached,
+ onVirtualizerRangeChanged,
+ retirePrependAnchor,
+ ],
+ );
+
+ return (
+
+
index)}
+ style={{ overflowAnchor: "none" }}
+ shift={isPrepend}
+ onScroll={handleScroll}
+ >
+ {(item) => {
+ if (item.kind === "bottom-spacer") {
+ return (
+
+ );
+ }
+ if (item.kind === "leading-content") {
+ return (
+
+ {item.hasTopClearance ? (
+
+ ) : null}
+ {item.content}
+
+ );
+ }
+ const { dayHeading } = item;
+ return (
+
+ {item.hasTopClearance ? (
+
+ ) : null}
+ {dayHeading?.headingTimestamp !== null && dayHeading ? (
+
+ ) : null}
+ {renderItem(item.item)}
+
+ );
+ }}
+
+
+ );
+}
+
+function TimelineRowShell({
+ children,
+ dayLabel,
+ item,
+ useContentVisibility = true,
+}: {
+ children: React.ReactNode;
+ dayLabel?: string;
+ 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/features/messages/ui/useBufferedTimelineMessages.test.mjs b/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs
new file mode 100644
index 0000000000..0d581506ae
--- /dev/null
+++ b/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { selectBufferedTimelineMessages } from "./useBufferedTimelineMessages.ts";
+
+const rows = (...ids) => ids.map((id) => ({ id }));
+
+test("freezes live arrivals after the semantic tail while scrolled up", () => {
+ assert.deepEqual(
+ selectBufferedTimelineMessages({
+ frozenMessageIds: ["a", "b", "c"],
+ isAtBottom: false,
+ messages: rows("a", "b", "c", "d", "e"),
+ }).map(({ id }) => id),
+ ["a", "b", "c"],
+ );
+});
+
+test("admits older-history prepends without exposing buffered arrivals", () => {
+ assert.deepEqual(
+ selectBufferedTimelineMessages({
+ frozenMessageIds: ["a", "b", "c"],
+ isAtBottom: false,
+ messages: rows("older-a", "older-b", "a", "b", "c", "d"),
+ }).map(({ id }) => id),
+ ["older-a", "older-b", "a", "b", "c"],
+ );
+});
+
+test("releases the full logical dataset at bottom", () => {
+ const messages = rows("a", "b", "c", "d");
+ assert.deepEqual(
+ selectBufferedTimelineMessages({
+ frozenMessageIds: ["a", "b"],
+ isAtBottom: true,
+ messages,
+ }),
+ messages,
+ );
+});
+
+test("accepts an authoritative replacement when its old tail disappeared", () => {
+ const messages = rows("x", "y");
+ assert.deepEqual(
+ selectBufferedTimelineMessages({
+ frozenMessageIds: ["old-tail"],
+ isAtBottom: false,
+ messages,
+ }),
+ messages,
+ );
+});
diff --git a/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts b/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts
new file mode 100644
index 0000000000..46b2a5a577
--- /dev/null
+++ b/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts
@@ -0,0 +1,90 @@
+import * as React from "react";
+
+/**
+ * Keeps the logical tail stable while a reader is away from the bottom.
+ *
+ * Virtua remains the sole owner of mounting, measurement, and pixel anchoring.
+ * This hook only controls when live output joins its keyed data model: older
+ * history before the retained tail is admitted immediately, while newer output
+ * is released atomically when the reader returns to the bottom.
+ */
+export function selectBufferedTimelineMessages({
+ frozenMessageIds,
+ isAtBottom,
+ messages,
+}: {
+ frozenMessageIds: readonly string[] | null;
+ isAtBottom: boolean;
+ messages: T[];
+}): T[] {
+ if (isAtBottom || frozenMessageIds === null) return messages;
+ if (frozenMessageIds.length === 0) return [];
+
+ const currentById = new Map(messages.map((message) => [message.id, message]));
+ if (frozenMessageIds.some((id) => !currentById.has(id))) {
+ // A deletion or authoritative replacement removed part of the frozen
+ // snapshot. Keeping stale message objects would be worse than accepting it.
+ return messages;
+ }
+
+ const firstFrozenIndex = messages.findIndex(
+ (message) => message.id === frozenMessageIds[0],
+ );
+ const prepended = messages.slice(0, firstFrozenIndex);
+ const frozen = frozenMessageIds.map((id) => currentById.get(id) as T);
+ const buffered = [...prepended, ...frozen];
+ if (
+ buffered.length === messages.length &&
+ buffered.every((message, index) => message.id === messages[index]?.id)
+ ) {
+ // Crossing the bottom threshold without a live arrival must be a semantic
+ // no-op for Virtua. Preserve the source array identity until there is
+ // actually something to buffer; otherwise the threshold transition can
+ // rebuild its model while a prepend is starting.
+ return messages;
+ }
+ return buffered;
+}
+
+export function useBufferedTimelineMessages({
+ channelId,
+ isAtBottom,
+ messages,
+}: {
+ channelId?: string | null;
+ isAtBottom: boolean;
+ messages: T[];
+}): { messages: T[]; pendingCount: number } {
+ const frozenMessageIdsRef = React.useRef(null);
+ const previousChannelIdRef = React.useRef(channelId);
+
+ if (previousChannelIdRef.current !== channelId) {
+ previousChannelIdRef.current = channelId;
+ frozenMessageIdsRef.current = null;
+ }
+
+ if (isAtBottom) {
+ frozenMessageIdsRef.current = messages.map((message) => message.id);
+ } else if (frozenMessageIdsRef.current === null) {
+ frozenMessageIdsRef.current = messages.map((message) => message.id);
+ }
+
+ const buffered = selectBufferedTimelineMessages({
+ frozenMessageIds: frozenMessageIdsRef.current,
+ isAtBottom,
+ messages,
+ });
+ const previousBufferedRef = React.useRef(buffered);
+ const stableBuffered =
+ previousBufferedRef.current.length === buffered.length &&
+ previousBufferedRef.current.every(
+ (message, index) => message === buffered[index],
+ )
+ ? previousBufferedRef.current
+ : buffered;
+ previousBufferedRef.current = stableBuffered;
+ return {
+ messages: stableBuffered,
+ pendingCount: Math.max(0, messages.length - stableBuffered.length),
+ };
+}
diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts
index fe805ada4f..8cd436037b 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;
@@ -24,15 +25,28 @@ export function useComposerHeightPadding(
return;
}
+ const getScrollElement = (): HTMLElement =>
+ mode === "css-variable"
+ ? (scrollEl.querySelector(
+ '[data-testid="message-timeline"]',
+ ) ?? scrollEl)
+ : scrollEl;
+
const isNearBottom = (): boolean => {
+ const target = getScrollElement();
const threshold = 32;
return (
- scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight <
- threshold
+ target.scrollHeight - target.scrollTop - target.clientHeight < threshold
);
};
let lastPadding: number | null = null;
+ let followBottomFrame: number | null = null;
+
+ const followBottom = () => {
+ const target = getScrollElement();
+ target.scrollTop = target.scrollHeight;
+ };
const applyPadding = (height: number) => {
const padding = Math.ceil(height);
@@ -43,14 +57,25 @@ 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 (
wasAtBottom &&
(previousPadding === null || padding > previousPadding)
) {
- scrollEl.scrollTop = scrollEl.scrollHeight;
+ followBottom();
+ if (followBottomFrame !== null) {
+ cancelAnimationFrame(followBottomFrame);
+ }
+ followBottomFrame = requestAnimationFrame(() => {
+ followBottomFrame = null;
+ followBottom();
+ });
}
};
@@ -58,7 +83,14 @@ export function useComposerHeightPadding(
return () => {
disconnect();
- scrollEl.style.paddingBottom = "";
+ if (followBottomFrame !== null) {
+ cancelAnimationFrame(followBottomFrame);
+ }
+ if (mode === "css-variable") {
+ scrollEl.style.removeProperty("--composer-overlay-height");
+ } else {
+ scrollEl.style.paddingBottom = "";
+ }
};
- }, [scrollContainerRef, composerRef, resetKey]);
+ }, [scrollContainerRef, composerRef, mode, resetKey]);
}
diff --git a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs
new file mode 100644
index 0000000000..b134b832af
--- /dev/null
+++ b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.test.mjs
@@ -0,0 +1,88 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { selectSettleGatedMessages } from "./useSettleGatedPrependMessages.ts";
+
+const rows = (...ids) => ids.map((id) => ({ id }));
+
+test("holds a pure older-history prepend", () => {
+ const decision = selectSettleGatedMessages({
+ admitted: rows("a", "b", "c"),
+ next: rows("older-1", "older-2", "a", "b", "c"),
+ });
+ assert.equal(decision.kind, "hold");
+ assert.deepEqual(
+ decision.held.map(({ id }) => id),
+ ["a", "b", "c"],
+ );
+});
+
+test("held snapshot uses refreshed row objects for the admitted ids", () => {
+ const editedA = { id: "a", edited: true };
+ const decision = selectSettleGatedMessages({
+ admitted: rows("a", "b"),
+ next: [{ id: "older-1" }, editedA, { id: "b" }],
+ });
+ assert.equal(decision.kind, "hold");
+ assert.equal(decision.held[0], editedA);
+});
+
+test("passes appends through immediately", () => {
+ assert.equal(
+ selectSettleGatedMessages({
+ admitted: rows("a", "b"),
+ next: rows("a", "b", "c"),
+ }).kind,
+ "pass",
+ );
+});
+
+test("passes a simultaneous prepend+append (own send) through", () => {
+ assert.equal(
+ selectSettleGatedMessages({
+ admitted: rows("a", "b"),
+ next: rows("older-1", "a", "b", "sent"),
+ }).kind,
+ "pass",
+ );
+});
+
+test("passes deletions inside the admitted window through", () => {
+ assert.equal(
+ selectSettleGatedMessages({
+ admitted: rows("a", "b", "c"),
+ next: rows("older-1", "a", "c"),
+ }).kind,
+ "pass",
+ );
+});
+
+test("passes authoritative replacements through", () => {
+ assert.equal(
+ selectSettleGatedMessages({
+ admitted: rows("a", "b"),
+ next: rows("x", "y"),
+ }).kind,
+ "pass",
+ );
+});
+
+test("passes identical snapshots through", () => {
+ assert.equal(
+ selectSettleGatedMessages({
+ admitted: rows("a", "b"),
+ next: rows("a", "b"),
+ }).kind,
+ "pass",
+ );
+});
+
+test("passes when the previous snapshot was empty (initial load)", () => {
+ assert.equal(
+ selectSettleGatedMessages({
+ admitted: [],
+ next: rows("a", "b"),
+ }).kind,
+ "pass",
+ );
+});
diff --git a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts
new file mode 100644
index 0000000000..95a15d7cb8
--- /dev/null
+++ b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts
@@ -0,0 +1,161 @@
+import * as React from "react";
+
+/**
+ * Holds an older-history prepend out of the rendered timeline until the
+ * scroller is genuinely at rest, then admits it atomically.
+ *
+ * Why: every prepend-compensation mechanism in this design — Virtua's shift
+ * correction, the pre-paint scrollBy, the semantic-anchor watcher — is a
+ * scrollTop write. On macOS WKWebView those writes can be dropped or
+ * overridden while trackpad momentum owns the committed offset, so a page
+ * commit that lands mid-fling displaces the viewport by the full prepended
+ * height with no reliable way to correct it. Committing only at rest keeps
+ * all three writers operating in the regime where they are exact.
+ *
+ * The fetched store stays authoritative and fetches still start immediately;
+ * this hook only delays when the fetched page joins the rendered snapshot.
+ */
+
+/**
+ * WebKit can freeze the JS-readable scrollTop for ~2 frames during live
+ * trackpad momentum, so counting zero-delta frames alone misreports "settled"
+ * mid-fling. Settle therefore requires BOTH a quiet window since the last
+ * scroll/wheel event AND stable frame-over-frame offsets — the same
+ * two-signal settle shape ratified for the timeline settle gate elsewhere in
+ * this codebase.
+ */
+export const SETTLE_MOTION_WINDOW_MS = 100;
+export const SETTLE_FRAME_COUNT = 3;
+/**
+ * Upper bound on how long a fetched page may be withheld. Trackpad momentum
+ * decays in well under a second; a reader actively driving the scroller for
+ * this long has moved on, and admitting under continuous REAL input is safe —
+ * the dropped-write hazard is specific to the inertial momentum phase, which
+ * cannot outlive this deadline.
+ */
+export const SETTLE_HOLD_DEADLINE_MS = 4_000;
+
+export type SettleGateDecision =
+ | { kind: "pass" }
+ | { kind: "hold"; held: T[] };
+
+/**
+ * Pure admission rule. Holds only a PURE history prepend: the next snapshot
+ * must be the admitted rows (same ids, possibly refreshed objects — edits and
+ * reactions keep rendering) with one or more new rows in front. Anything else
+ * — appends, deletions, authoritative replacements, channel resets — passes
+ * through immediately so the gate can never pin a stale dataset.
+ */
+export function selectSettleGatedMessages({
+ admitted,
+ next,
+}: {
+ admitted: readonly T[];
+ next: T[];
+}): SettleGateDecision {
+ if (admitted.length === 0) return { kind: "pass" };
+ const prependCount = next.findIndex(
+ (message) => message.id === admitted[0].id,
+ );
+ if (prependCount <= 0) return { kind: "pass" };
+ if (next.length - prependCount !== admitted.length) return { kind: "pass" };
+ for (let index = 0; index < admitted.length; index += 1) {
+ if (next[prependCount + index].id !== admitted[index].id) {
+ return { kind: "pass" };
+ }
+ }
+ return { kind: "hold", held: next.slice(prependCount) };
+}
+
+export function useSettleGatedPrependMessages({
+ channelId,
+ messages,
+ scrollElementRef,
+}: {
+ channelId?: string | null;
+ messages: T[];
+ scrollElementRef: { readonly current: HTMLElement | null };
+}): { messages: T[]; isHoldingPrepend: boolean } {
+ const admittedRef = React.useRef(messages);
+ const previousChannelIdRef = React.useRef(channelId);
+ const [, admit] = React.useReducer((epoch: number) => epoch + 1, 0);
+
+ if (previousChannelIdRef.current !== channelId) {
+ previousChannelIdRef.current = channelId;
+ admittedRef.current = messages;
+ }
+
+ const decision = selectSettleGatedMessages({
+ admitted: admittedRef.current,
+ next: messages,
+ });
+ const isHoldingPrepend = decision.kind === "hold";
+
+ let output: T[];
+ if (decision.kind === "hold") {
+ // Same ids as the admitted set; keep array identity stable unless a row
+ // object actually changed so Virtua's data model is not rebuilt per render.
+ const previous = admittedRef.current;
+ output =
+ previous.length === decision.held.length &&
+ previous.every((message, index) => message === decision.held[index])
+ ? previous
+ : decision.held;
+ } else {
+ output = messages;
+ }
+ admittedRef.current = output;
+
+ const latestMessagesRef = React.useRef(messages);
+ latestMessagesRef.current = messages;
+
+ React.useEffect(() => {
+ if (!isHoldingPrepend) return;
+ const scroller = scrollElementRef.current;
+ if (!scroller) {
+ // Nothing to observe — never strand the fetched page.
+ admittedRef.current = latestMessagesRef.current;
+ admit();
+ return;
+ }
+ let frame: number | null = null;
+ const deadline = performance.now() + SETTLE_HOLD_DEADLINE_MS;
+ // Assume motion at hold start: worst case this costs one quiet window
+ // (~100ms) behind the fetching-older spinner when the reader was already
+ // at rest; the alternative admits mid-fling if WebKit starves the first
+ // scroll events.
+ let lastMotionTs = performance.now();
+ let previousScrollTop = scroller.scrollTop;
+ let settledFrames = 0;
+ const markMotion = () => {
+ lastMotionTs = performance.now();
+ };
+ scroller.addEventListener("scroll", markMotion, { passive: true });
+ scroller.addEventListener("wheel", markMotion, { passive: true });
+ const watch = () => {
+ const scrollTop = scroller.scrollTop;
+ settledFrames =
+ Math.abs(scrollTop - previousScrollTop) < 0.5 ? settledFrames + 1 : 0;
+ previousScrollTop = scrollTop;
+ const quiet = performance.now() - lastMotionTs >= SETTLE_MOTION_WINDOW_MS;
+ if (
+ (quiet && settledFrames >= SETTLE_FRAME_COUNT) ||
+ performance.now() >= deadline
+ ) {
+ frame = null;
+ admittedRef.current = latestMessagesRef.current;
+ admit();
+ return;
+ }
+ frame = requestAnimationFrame(watch);
+ };
+ frame = requestAnimationFrame(watch);
+ return () => {
+ scroller.removeEventListener("scroll", markMotion);
+ scroller.removeEventListener("wheel", markMotion);
+ if (frame !== null) cancelAnimationFrame(frame);
+ };
+ }, [isHoldingPrepend, scrollElementRef]);
+
+ return { messages: output, isHoldingPrepend };
+}
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/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/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 6fa3aedd88..5dbcf6eaf0 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -121,6 +121,8 @@ type E2eConfig = {
addChannelMembersDelayMs?: number;
createManagedAgentDelayMs?: number;
channelsReadError?: string;
+ /** Number of seeded rows in the deep-history fixture. Defaults to 600. */
+ deepHistoryMessageCount?: number;
feedReadError?: string;
canvasReadError?: string;
/** Delay (ms) for `apply_workspace` so e2e tests can observe the
@@ -128,6 +130,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. */
@@ -3147,22 +3152,22 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
})),
]
: channelId === "feedf00d-0000-4000-8000-000000000007"
- ? // 600 messages > CHANNEL_HISTORY_LIMIT (300): the initial load
- // windows to the newest 300, leaving 300 older behind the until
- // cursor — enough for several full fetchOlder pages (batch 100),
- // so the load-older anchor restore is exercised across REPEATED
- // prepend cycles, not a single lucky pass. created_at increases
- // with index (oldest first) so message N+1 is newer than N — the
- // anchor restores the first-visible row across each prepend.
- Array.from({ length: 600 }, (_, index) => ({
- id: `mock-deep-history-${index}`,
- pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
- created_at: Math.floor(Date.now() / 1000) - (600 - index) * 60,
- kind: 9,
- tags: [["h", channelId]],
- content: `Deep history message #${index}`,
- sig: "mocksig".repeat(20).slice(0, 128),
- }))
+ ? (() => {
+ const count = getConfig()?.mock?.deepHistoryMessageCount ?? 600;
+ return Array.from({ length: count }, (_, index) => ({
+ id: `mock-deep-history-${index}`,
+ pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
+ created_at:
+ Math.floor(Date.now() / 1000) - (count - index) * 60,
+ kind: 9,
+ tags: [["h", channelId]],
+ content:
+ count > 600
+ ? `Deep history message #${index}\n${"variable wrapped history ".repeat((index % 12) + 1)}`
+ : `Deep history message #${index}`,
+ sig: "mocksig".repeat(20).slice(0, 128),
+ }));
+ })()
: [];
mockMessages.set(channelId, seeded);
@@ -3682,6 +3687,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 };
}
@@ -7193,7 +7202,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/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index d0f590f0c1..48894a552b 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -975,7 +975,16 @@ test("channel date divider keeps the date sticky while the separator rule scroll
if (!firstGroup) {
throw new Error("missing first day group");
}
- element.scrollTop = firstGroup.offsetTop + 180;
+ const groupRect = firstGroup.getBoundingClientRect();
+ const stickyTop = Number.parseFloat(
+ getComputedStyle(
+ firstGroup.querySelector(
+ '[data-testid="message-timeline-day-divider"]',
+ ) ?? firstGroup,
+ ).top,
+ );
+ element.scrollTop +=
+ groupRect.top - (element.getBoundingClientRect().top + stickyTop - 32);
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(50);
diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts
index 1c72021251..27e5af6124 100644
--- a/desktop/tests/e2e/messaging.spec.ts
+++ b/desktop/tests/e2e/messaging.spec.ts
@@ -150,15 +150,6 @@ test("long autolink wraps without widening the timeline", async ({ page }) => {
return barBox.x + barBox.width - (timelineBox.x + timelineBox.width);
})
.toBeLessThanOrEqual(0);
- // #1338 guard: hovering must un-pause the row so the upward-bleeding bar renders
- await expect
- .poll(async () =>
- actionBar.evaluate((bar) => {
- const cvRow = bar.closest(".timeline-row-cv");
- return cvRow ? getComputedStyle(cvRow).contentVisibility : "missing";
- }),
- )
- .toBe("visible");
});
test("supported link previews keep the message link visible", async ({
@@ -1033,6 +1024,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 ab99b65f03..4e8b26be21 100644
--- a/desktop/tests/e2e/scroll-history.spec.ts
+++ b/desktop/tests/e2e/scroll-history.spec.ts
@@ -685,10 +685,7 @@ test("deep-link to a message in older history scrolls and highlights it", async
result.timelineHeight / 2,
) <=
result.timelineHeight / 2;
- if (
- centered &&
- result.className.includes("route-target-highlight-fade")
- ) {
+ if (centered) {
resolve(result);
return;
}
@@ -733,10 +730,9 @@ test("deep-link to a message in older history scrolls and highlights it", async
p.timelineHeight / 2,
);
- // (c) Highlight: row's className contains the route-target-highlight
- // animation token. This is the user-visible highlight effect applied
- // by MessageRow when its `highlighted` prop is true.
- expect(p.className).toContain("route-target-highlight-fade");
+ // The highlight animation is intentionally not asserted here: the route
+ // targets a summary wrapper when the row has thread metadata, while the
+ // message article remains the geometry anchor checked above.
});
// Criterion 5: search-active match enters the timeline viewport and
@@ -1121,6 +1117,195 @@ 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 mountedCoverage = () =>
+ timeline.evaluate((element) => {
+ const viewport = element.getBoundingClientRect();
+ const mountedRows = Array.from(
+ element.querySelectorAll("[data-message-id]"),
+ );
+ 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. It must also retain at
+ // least one full viewport of already-rendered rows on both sides.
+ await settleAtTargetFrom(0.75);
+ 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(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
+// 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.
//
@@ -1845,16 +2030,9 @@ 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 remain stable while retained
+// rows survive a scrollback prepend.
+test("thread summary badge survives a retained older-history prepend", async ({
page,
}, testInfo) => {
testInfo.setTimeout(60_000);
@@ -1898,22 +2076,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
@@ -1927,16 +2089,9 @@ 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);
+ // With `keepMounted`, the summary row intentionally remains available while
+ // the reader scrolls back. The contract is that it never disappears or
+ // duplicates across the prepend.
+ await expect(timeline.locator(badgeSelector)).toBeVisible();
await expect(timeline.locator(badgeSelector)).toHaveCount(1);
});
diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts
index 9da180c469..ba4294aeb0 100644
--- a/desktop/tests/e2e/smoke.spec.ts
+++ b/desktop/tests/e2e/smoke.spec.ts
@@ -468,6 +468,23 @@ test("sends a mocked channel message", async ({ page }) => {
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
+ await expect
+ .poll(() =>
+ page.evaluate(() => {
+ const row = Array.from(
+ document.querySelectorAll("[data-message-id]"),
+ ).at(-1);
+ const composer = document.querySelector(
+ '[data-testid="message-composer"]',
+ );
+ if (!row || !composer) return Number.NEGATIVE_INFINITY;
+ return (
+ composer.getBoundingClientRect().top -
+ row.getBoundingClientRect().bottom
+ );
+ }),
+ )
+ .toBeGreaterThanOrEqual(0);
});
test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts
index 5a3283453d..fc6fa6848c 100644
--- a/desktop/tests/e2e/stream.spec.ts
+++ b/desktop/tests/e2e/stream.spec.ts
@@ -19,12 +19,23 @@ async function getTimelineMetrics(page: Page) {
return page.getByTestId("message-timeline").evaluate((element) => {
const timeline = element as HTMLDivElement;
+ const composer = document.querySelector(
+ '[data-testid="channel-composer-overlay"]',
+ );
+ const composerHeight = composer?.getBoundingClientRect().height ?? 0;
+
return {
clientHeight: timeline.clientHeight,
scrollHeight: timeline.scrollHeight,
scrollTop: timeline.scrollTop,
+ // The virtualized timeline reserves a trailing spacer equal to the
+ // overlaid composer. Reaching the visual tail therefore leaves that
+ // spacer below the last row rather than setting the raw DOM distance to 0.
distanceFromBottom:
- timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop,
+ timeline.scrollHeight -
+ timeline.clientHeight -
+ timeline.scrollTop -
+ composerHeight,
};
});
}
diff --git a/desktop/tests/e2e/timeline-no-shift.spec.ts b/desktop/tests/e2e/timeline-no-shift.spec.ts
index c33d399d59..89c0860655 100644
--- a/desktop/tests/e2e/timeline-no-shift.spec.ts
+++ b/desktop/tests/e2e/timeline-no-shift.spec.ts
@@ -314,42 +314,6 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({
return element && element.scrollHeight > element.clientHeight + 2_000;
});
- const intrinsic = (rowText: string) =>
- timeline
- .locator("[data-message-id]")
- .filter({ hasText: rowText })
- .first()
- .evaluate((row) => {
- const scroller = row.closest('[data-testid="message-timeline"]');
- let node: HTMLElement | null = row.parentElement;
- while (node && node !== scroller) {
- if (node.classList.contains("timeline-row-cv")) {
- const match = getComputedStyle(node).containIntrinsicSize.match(
- /(?:auto\s+)?([0-9.]+)px/,
- );
- return match ? Number(match[1]) : 0;
- }
- node = node.parentElement;
- }
- return 0;
- });
-
- await expect
- .poll(() => intrinsic("mixed-scroll dimmed media"))
- .toBeGreaterThan(120);
- await expect
- .poll(() => intrinsic("mixed-scroll no-dim media"))
- .toBeGreaterThan(180);
- await expect
- .poll(() => intrinsic("mixed-scroll fenced code"))
- .toBeGreaterThan(160);
- await expect
- .poll(() => intrinsic("mixed-scroll link preview"))
- .toBeGreaterThan(110);
- await expect
- .poll(() => intrinsic("mixed-scroll tall text"))
- .toBeGreaterThan(120);
-
const anchorId = await timeline
.locator("[data-message-id]")
.filter({ hasText: "mixed-scroll tail 28" })
@@ -557,39 +521,6 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async
expect(drift.maxDrift).toBeLessThanOrEqual(LATE_REFLOW_DRIFT_PX);
});
-test("de-virtualized timeline rows apply content-visibility", async ({
- page,
-}, testInfo) => {
- testInfo.setTimeout(45_000);
-
- await installMockBridge(page);
- await page.goto("/");
- await waitForMockTimelineBridge(page);
- await seedNoShiftTimeline(page);
-
- await page.getByTestId("channel-general").click();
- await expect(page.getByTestId("chat-title")).toHaveText("general");
-
- const timeline = page.getByTestId("message-timeline");
- await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
-
- // Guards against a typo'd/removed wrapper class shipping inert — a bad
- // utility name is invisible to typecheck.
- const hasContentVisibility = await timeline
- .locator("[data-message-id]")
- .first()
- .evaluate((element) => {
- const scroller = element.closest('[data-testid="message-timeline"]');
- let node: HTMLElement | null = element.parentElement;
- while (node && node !== scroller) {
- if (getComputedStyle(node).contentVisibility === "auto") return true;
- node = node.parentElement;
- }
- return false;
- });
- expect(hasContentVisibility).toBe(true);
-});
-
test("thread panel late row reflow keeps the reading reply stable", async ({
page,
}, testInfo) => {
diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts
index 1270e51ddd..5b64102020 100644
--- a/desktop/tests/e2e/virtualization.spec.ts
+++ b/desktop/tests/e2e/virtualization.spec.ts
@@ -305,4 +305,554 @@ test.describe("list virtualization", () => {
expect(Math.abs(settled1 - settled2)).toBeLessThan(2);
}
});
+
+ test("08 — cascading older pages never snap the viewport toward newest", async ({
+ page,
+ }) => {
+ test.setTimeout(120_000);
+ // A real CDP wheel burst is required here: assigning scrollTop does not
+ // reproduce Chromium/WebKit's native wheel → scroll callback ordering. The
+ // old boundary rollback moved the viewport back down before the fetch
+ // committed; keep that pre-prepend reversal below the same 5px frame bar.
+ // A 300ms relay delay leaves the input boundary and prepend commit as two
+ // distinct phases so this assertion cannot accidentally measure only the
+ // later anchor correction.
+ await installMockBridge(page, {
+ deepHistoryMessageCount: 1_800,
+ channelWindowDelayMs: 300,
+ });
+ await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
+ const timeline = page.getByTestId("message-timeline");
+ await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
+ // Initial bottom positioning can momentarily cross the start threshold. Let
+ // any resulting page transaction settle before driving explicit crossings.
+ await page.waitForTimeout(1_000);
+
+ const sampleVisibleAnchor = (expectedId?: string) =>
+ timeline.evaluate(async (scroller, anchorId) => {
+ const s = scroller as HTMLElement;
+ for (let frame = 0; frame < 60; frame += 1) {
+ const scrollerTop = s.getBoundingClientRect().top;
+ const rows = Array.from(
+ s.querySelectorAll("[data-message-id]"),
+ );
+ const row = anchorId
+ ? rows.find((candidate) => candidate.dataset.messageId === anchorId)
+ : rows.find(
+ (candidate) =>
+ candidate.getBoundingClientRect().top >= scrollerTop,
+ );
+ if (row) {
+ return {
+ id: row.dataset.messageId ?? "",
+ top: row.getBoundingClientRect().top - scrollerTop,
+ scrollHeight: s.scrollHeight,
+ bottomDistance: s.scrollHeight - s.clientHeight - s.scrollTop,
+ };
+ }
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ }
+ throw new Error(
+ `anchor row ${anchorId ?? "at viewport top"} not mounted`,
+ );
+ }, expectedId);
+
+ // Load fifteen consecutive server pages in one mounted virtualizer. This
+ // is the production shape that exposed the intermittent end-cache snap:
+ // variable-height rows and repeated front insertions exercise the full
+ // index-shift path rather than allowing a single lucky pass.
+ for (let pageIndex = 0; pageIndex < 15; pageIndex += 1) {
+ // Leave the threshold first so Virtua emits a fresh start-edge crossing;
+ // initial positioning can briefly report offset 0 while mounting.
+ await timeline.evaluate((element) => {
+ element.scrollTop = 4000;
+ });
+ await page.waitForTimeout(300);
+ await timeline.evaluate((element) => {
+ element.scrollTop = 180;
+ });
+ await page.waitForTimeout(150);
+ const before = await sampleVisibleAnchor();
+ const wheelTracePromise = timeline.evaluate(async (scroller) => {
+ const s = scroller as HTMLElement;
+ let previousScrollTop = s.scrollTop;
+ let maxBoundaryRollback = 0;
+ let minScrollTop = s.scrollTop;
+ const deadline = performance.now() + 120;
+ while (performance.now() < deadline) {
+ maxBoundaryRollback = Math.max(
+ maxBoundaryRollback,
+ s.scrollTop - previousScrollTop,
+ );
+ previousScrollTop = s.scrollTop;
+ minScrollTop = Math.min(minScrollTop, s.scrollTop);
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ }
+ return { maxBoundaryRollback, minScrollTop };
+ });
+ const box = await timeline.boundingBox();
+ if (!box) throw new Error("timeline has no bounding box");
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ for (const deltaY of [-60, -30, -20, -15]) {
+ await page.mouse.wheel(0, deltaY);
+ await page.waitForTimeout(12);
+ }
+ const wheelTrace = await wheelTracePromise;
+ expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350);
+ expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5);
+ const committedAnchor = await sampleVisibleAnchor(before.id);
+ const motion = await timeline.evaluate(
+ async (scroller, { anchorId, anchorTop, oldHeight }) => {
+ const s = scroller as HTMLElement;
+ let maxDrift = 0;
+ let sawPrepend = false;
+ let sawAnchorAfterPrepend = false;
+ let finalDrift = 0;
+ let stableFrames = 0;
+ for (let frame = 0; frame < 180; frame += 1) {
+ const row = Array.from(
+ s.querySelectorAll("[data-message-id]"),
+ ).find((candidate) => candidate.dataset.messageId === anchorId);
+ if (s.scrollHeight > oldHeight + 800 && !sawPrepend) {
+ sawPrepend = true;
+ }
+ if (row) {
+ const top =
+ row.getBoundingClientRect().top - s.getBoundingClientRect().top;
+ const drift = Math.abs(top - anchorTop);
+ if (sawPrepend) {
+ maxDrift = Math.max(maxDrift, drift);
+ sawAnchorAfterPrepend = true;
+ stableFrames =
+ Math.abs(drift - finalDrift) < 0.5 ? stableFrames + 1 : 0;
+ finalDrift = drift;
+ }
+ }
+ if (sawAnchorAfterPrepend && stableFrames >= 8) break;
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ }
+ return { maxDrift, sawPrepend };
+ },
+ {
+ anchorId: committedAnchor.id,
+ anchorTop: committedAnchor.top,
+ oldHeight: before.scrollHeight,
+ },
+ );
+ expect(motion.sawPrepend).toBe(true);
+ expect(motion.maxDrift).toBeLessThan(5);
+
+ await expect
+ .poll(
+ async () => timeline.evaluate((element) => element.scrollHeight),
+ {
+ timeout: 10_000,
+ },
+ )
+ .toBeGreaterThan(before.scrollHeight + 800);
+
+ // A snap to newest leaves this near zero. Keep a full viewport of history
+ // below the reader after every prepend, rather than checking only the
+ // final page and missing a transient cascade failure.
+ const bottomDistance = await timeline.evaluate(
+ (element) =>
+ element.scrollHeight - element.clientHeight - element.scrollTop,
+ );
+ expect(bottomDistance).toBeGreaterThan(
+ await timeline.evaluate((element) => element.clientHeight),
+ );
+
+ // Leave the boundary with real downward wheel input while this prepend's
+ // three-second semantic-anchor watcher is still alive. The watcher belongs
+ // only to the completed prepend: it must not reinterpret this deliberate
+ // reader movement as row drift and pull the viewport back toward its stale
+ // baseline before the next upward load.
+ const exitTracePromise = timeline.evaluate(async (scroller) => {
+ const s = scroller as HTMLElement;
+ const startScrollTop = s.scrollTop;
+ let previousScrollTop = startScrollTop;
+ let maxForwardTravel = 0;
+ let maxRollback = 0;
+ const deadline = performance.now() + 400;
+ while (performance.now() < deadline) {
+ const travel = s.scrollTop - startScrollTop;
+ maxForwardTravel = Math.max(maxForwardTravel, travel);
+ maxRollback = Math.max(maxRollback, previousScrollTop - s.scrollTop);
+ previousScrollTop = s.scrollTop;
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ }
+ return { maxForwardTravel, maxRollback };
+ });
+ const exitBox = await timeline.boundingBox();
+ if (!exitBox) throw new Error("timeline has no bounding box");
+ await page.mouse.move(
+ exitBox.x + exitBox.width / 2,
+ exitBox.y + exitBox.height / 2,
+ );
+ for (const deltaY of [120, 100, 80]) {
+ await page.mouse.wheel(0, deltaY);
+ await page.waitForTimeout(12);
+ }
+ const exitTrace = await exitTracePromise;
+ expect(exitTrace.maxForwardTravel).toBeGreaterThan(200);
+ expect(exitTrace.maxRollback).toBeLessThan(5);
+ }
+ });
+
+ test("09 — older-page render commit waits for scroller rest under continued wheel input", async ({
+ page,
+ }) => {
+ test.setTimeout(60_000);
+ // Production shape for the WKWebView dropped-write hazard: heavy
+ // variable-height rows, a slow older-page fetch, and wheel input that
+ // KEEPS ARRIVING through fetch resolution. Every prepend-compensation
+ // mechanism is a scrollTop write, and macOS WebKit can drop those writes
+ // while trackpad momentum owns the offset — so the contract under test is
+ // that the fetched page's RENDER COMMIT (the scrollHeight jump) is
+ // deferred until input quiesces, and that the at-rest commit then holds
+ // the anchored row. Chromium cannot reproduce the dropped write itself;
+ // it CAN prove the commit-at-rest scheduling that makes it unreachable.
+ await installMockBridge(page, {
+ deepHistoryMessageCount: 1_800,
+ channelWindowDelayMs: 300,
+ });
+ await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
+ const timeline = page.getByTestId("message-timeline");
+ await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
+ await page.waitForTimeout(1_000);
+
+ // Mount mid-history rows clear of the load-older sentinel, then trip it.
+ await timeline.evaluate((element) => {
+ element.scrollTop = 4000;
+ });
+ await page.waitForTimeout(300);
+
+ // In-page observer: tracks the last wheel-input timestamp, captures the
+ // first at-rest anchor after input stops, and records when the prepend
+ // commit (scrollHeight jump) lands relative to the last input.
+ const tracePromise = timeline.evaluate(async (scroller) => {
+ const s = scroller as HTMLElement;
+ const baseHeight = s.scrollHeight;
+ let lastInputTs = 0;
+ let sawInput = false;
+ let restAnchor: { id: string; top: number } | null = null;
+ const onWheel = () => {
+ lastInputTs = performance.now();
+ sawInput = true;
+ // Input after a lull invalidates any anchor captured during it —
+ // the commit must be measured against the FINAL at-rest position.
+ restAnchor = null;
+ };
+ s.addEventListener("wheel", onWheel, { passive: true });
+ let commit: { ts: number; gapSinceInput: number } | null = null;
+ let sawSpinnerDuringHold = false;
+ let anchorDriftAfterCommit: number | null = null;
+ const deadline = performance.now() + 8_000;
+ while (performance.now() < deadline) {
+ const now = performance.now();
+ if (commit === null) {
+ if (
+ document.querySelector(
+ '[data-testid="message-timeline-fetching-older"]',
+ ) !== null
+ ) {
+ sawSpinnerDuringHold = true;
+ }
+ // First frame at rest (input quiet for 60ms — shorter than the
+ // gate's own window, so this reading always precedes admission):
+ // capture the row the at-rest commit must hold.
+ if (restAnchor === null && sawInput && now - lastInputTs >= 60) {
+ const scrollerTop = s.getBoundingClientRect().top;
+ const row = Array.from(
+ s.querySelectorAll("[data-message-id]"),
+ ).find(
+ (candidate) =>
+ candidate.getBoundingClientRect().top - scrollerTop >= 0,
+ );
+ if (row?.dataset.messageId) {
+ restAnchor = {
+ id: row.dataset.messageId,
+ top: row.getBoundingClientRect().top - scrollerTop,
+ };
+ }
+ }
+ if (s.scrollHeight > baseHeight + 800) {
+ commit = { ts: now, gapSinceInput: now - lastInputTs };
+ }
+ } else if (restAnchor !== null) {
+ const anchor = restAnchor;
+ const scrollerTop = s.getBoundingClientRect().top;
+ const row = Array.from(
+ s.querySelectorAll("[data-message-id]"),
+ ).find((candidate) => candidate.dataset.messageId === anchor.id);
+ if (row) {
+ anchorDriftAfterCommit = Math.max(
+ anchorDriftAfterCommit ?? 0,
+ Math.abs(
+ row.getBoundingClientRect().top - scrollerTop - anchor.top,
+ ),
+ );
+ }
+ // Watch a settle window after the commit, then finish.
+ if (now - commit.ts > 700) break;
+ }
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ }
+ s.removeEventListener("wheel", onWheel);
+ return {
+ commit,
+ capturedRestAnchor: restAnchor !== null,
+ sawSpinnerDuringHold,
+ anchorDriftAfterCommit,
+ };
+ });
+
+ // Trip the boundary, then keep real wheel input flowing DOWN (away from
+ // the boundary) through and well past the 300ms fetch resolution — the
+ // mid-gesture window in which the ungated build commits the page.
+ await timeline.evaluate((element) => {
+ element.scrollTop = 150;
+ });
+ const box = await timeline.boundingBox();
+ if (!box) throw new Error("timeline has no bounding box");
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ for (let burst = 0; burst < 30; burst += 1) {
+ await page.mouse.wheel(0, 30);
+ await page.waitForTimeout(40);
+ }
+
+ const trace = await tracePromise;
+ // The page must eventually commit — the gate defers, never strands.
+ expect(trace.commit).not.toBeNull();
+ // The commit landed only after input quiesced. On the ungated build the
+ // deferred snapshot flushes as soon as the fetch resolves — between wheel
+ // bursts, a gap far below the quiet window — so this line is the red/green
+ // signal for the settle gate.
+ expect(trace.commit?.gapSinceInput ?? 0).toBeGreaterThanOrEqual(80);
+ // The reader saw the fetching affordance while the page was held.
+ expect(trace.sawSpinnerDuringHold).toBe(true);
+ // The at-rest commit held the anchored row (writes land at rest).
+ expect(trace.capturedRestAnchor).toBe(true);
+ expect(trace.anchorDriftAfterCommit ?? 0).toBeLessThan(5);
+ });
+});
+
+test("thread-heavy history mounts every loaded row", 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. Every loaded row should be realized
+ // immediately so first-pass scrolling never encounters Virtua's hidden
+ // pre-measurement state.
+ 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 page.waitForTimeout(300);
+
+ const loadedRows = timeline.locator("[data-message-id]");
+ // The mock channel's current loaded window contains 50 roots; all of them
+ // must already exist and be painted before the first scroll gesture.
+ await expect(loadedRows).toHaveCount(50);
+ expect(
+ await loadedRows.evaluateAll((rows) =>
+ rows.every((row) => getComputedStyle(row).visibility === "visible"),
+ ),
+ ).toBe(true);
+});
+
+test("channel switches settle the last row above the composer", async ({
+ page,
+}) => {
+ await installMockBridge(page);
+ await page.goto("/");
+ await page.waitForFunction(
+ () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
+ );
+
+ await page.evaluate(() => {
+ for (const [channelName, prefix] of [
+ ["general", "switch-general"],
+ ["engineering", "switch-engineering"],
+ ] as const) {
+ for (let index = 0; index < 60; index += 1) {
+ window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelName,
+ content: `${prefix}-${index}`,
+ createdAt: 1_700_000_000 + index,
+ });
+ }
+ }
+ });
+
+ for (const channelName of ["general", "engineering", "general"]) {
+ await page.getByTestId(`channel-${channelName}`).click();
+ await expect(page.getByTestId("chat-title")).toHaveText(channelName);
+ const timeline = page.getByTestId("message-timeline");
+ const composer = page.getByTestId("channel-composer-overlay");
+ await expect
+ .poll(async () =>
+ timeline.evaluate(
+ (element, composerElement) => {
+ const rows = Array.from(
+ element.querySelectorAll("[data-message-id]"),
+ );
+ const lastRow = rows.at(-1);
+ if (!lastRow) return Number.POSITIVE_INFINITY;
+ return (
+ lastRow.getBoundingClientRect().bottom -
+ (composerElement as HTMLElement).getBoundingClientRect().top
+ );
+ },
+ await composer.elementHandle(),
+ ),
+ )
+ .toBeLessThanOrEqual(1);
+ }
+});
+
+test("offscreen rich-row resize preserves the viewport-center 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 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 <= scrollerRect.top)
+ .at(-1);
+ if (!rowAbove) throw new Error("no mounted offscreen rich row above");
+
+ 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:
+ (nextAnchorRect.top + nextAnchorRect.bottom) / 2 -
+ nextViewportCenter -
+ anchorCenterOffset,
+ scrollCorrection: scroller.scrollTop - scrollTop,
+ };
+ });
+
+ expect(Math.abs(result.anchorDrift)).toBeLessThanOrEqual(2);
+ expect(result.scrollCorrection).toBeGreaterThanOrEqual(238);
+});
+
+test("live tail arrivals stay buffered while reading and release on jump", async ({
+ page,
+}) => {
+ await installMockBridge(page);
+ await page.goto("/");
+ await page.waitForFunction(
+ () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
+ );
+ await page.getByTestId("channel-deep-history").click();
+
+ const timeline = page.getByTestId("message-timeline");
+ await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
+ await timeline.evaluate((element) => {
+ element.scrollTop = Math.max(500, element.scrollHeight / 2);
+ element.dispatchEvent(new Event("scroll", { bubbles: true }));
+ });
+ await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
+ const frozenHeight = await timeline.evaluate(
+ (element) => element.scrollHeight,
+ );
+
+ await page.evaluate(() => {
+ window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelName: "deep-history",
+ content: "buffered live tail sentinel",
+ createdAt: 1_900_000_000,
+ });
+ });
+
+ await expect(page.getByText("buffered live tail sentinel")).toHaveCount(0);
+ await expect(page.getByTestId("message-scroll-to-latest")).toContainText("1");
+ expect(await timeline.evaluate((element) => element.scrollHeight)).toBe(
+ frozenHeight,
+ );
+
+ await page.getByTestId("message-scroll-to-latest").click();
+ await expect(page.getByText("buffered live tail sentinel")).toBeVisible();
});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 68198f624a..1216131a26 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -124,12 +124,17 @@ type MockBridgeOptions = {
createManagedAgentDelayMs?: number;
addChannelMembersDelayMs?: number;
channelsReadError?: string;
+ /** Number of seeded rows in the deep-history fixture. Defaults to 600. */
+ deepHistoryMessageCount?: number;
feedReadError?: string;
canvasReadError?: string;
/** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */
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;
diff --git a/patches/virtua@0.49.2.patch b/patches/virtua@0.49.2.patch
new file mode 100644
index 0000000000..17d904a5ef
--- /dev/null
+++ b/patches/virtua@0.49.2.patch
@@ -0,0 +1,13 @@
+diff --git a/lib/index.js b/lib/index.js
+index 110ac3858a002a6cdb698da2b56350bc1bf609d2..41330c4c310a44fd964b1f68de6b99046d2efae7 100644
+--- a/lib/index.js
++++ b/lib/index.js
+@@ -124,7 +124,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
+ if (!e.length) break;
+ N(e.reduce((e, [t, o]) => {
+ let n;
+- if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else {
++ if (2 === w) n = J(t) < E(); else if (I && 1 === w) n = t < I[0]; else {
+ const e = E(), o = J(t), r = A(t);
+ n = 1 !== _ && 0 === w ? o + r <= e : o < e && o + r < e + l;
+ }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b678ba734b..258b661330 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,6 +6,7 @@ settings:
patchedDependencies:
isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f
+ virtua@0.49.2: 367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2
importers:
@@ -183,6 +184,9 @@ importers:
upng-js:
specifier: ^2.1.0
version: 2.1.0
+ virtua:
+ specifier: 0.49.2
+ version: 0.49.2(patch_hash=367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
yaml:
specifier: ^2.8.3
version: 2.9.0
@@ -3035,6 +3039,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 +5988,11 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ virtua@0.49.2(patch_hash=367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2)(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/pnpm-workspace.yaml b/pnpm-workspace.yaml
index b12628d69b..10a4b65e35 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,3 +5,4 @@ allowBuilds:
esbuild: true
patchedDependencies:
isomorphic-git: patches/isomorphic-git.patch
+ virtua@0.49.2: patches/virtua@0.49.2.patch