Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ const MessageTimelineBase = React.forwardRef<
const showTimelineSkeleton = timelineBodySurface === "skeleton";

const {
captureAnchorBeforePrepend,
highlightedMessageId,
isAtBottom,
newMessageCount,
Expand Down Expand Up @@ -358,8 +359,14 @@ const MessageTimelineBase = React.forwardRef<
}
}, [deferredMessages, jumpToMessage, showTimelineSkeleton]);

const fetchOlderWithAnchorCapture = React.useCallback(async () => {
if (!fetchOlder) return;
captureAnchorBeforePrepend();
await fetchOlder();
}, [captureAnchorBeforePrepend, fetchOlder]);

useLoadOlderOnScroll({
fetchOlder,
fetchOlder: fetchOlder ? fetchOlderWithAnchorCapture : undefined,
hasOlderMessages,
isLoading: showTimelineSkeleton,
scrollContainerRef,
Expand Down
34 changes: 32 additions & 2 deletions desktop/src/features/messages/ui/useAnchoredScroll.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";

import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
import { useFeatureEnabled } from "@/shared/features";

/**
* Distance (in CSS pixels) below which we consider the scroll position
Expand All @@ -18,6 +19,7 @@ const TRUE_BOTTOM_THRESHOLD_PX = 1;

type AnchorState =
| { kind: "at-bottom" }
| { kind: "away" }
| { kind: "message"; messageId: string; topOffset: number };

type BottomSettleContainer = Pick<
Expand Down Expand Up @@ -61,6 +63,8 @@ type UseAnchoredScrollResult = {
newMessageCount: number;
/** Message id that should pulse a highlight (target/active-search). */
highlightedMessageId: string | null;
/** Capture the visible row immediately before older history is fetched. */
captureAnchorBeforePrepend: () => void;
/** Imperative: scroll to bottom. */
scrollToBottom: (behavior?: ScrollBehavior) => void;
/** Arm a one-shot scroll-to-bottom that fires on the next appended message
Expand Down Expand Up @@ -151,6 +155,9 @@ export function useAnchoredScroll({
targetMessageId = null,
onTargetReached,
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
const captureAnchorAtMutationBoundary = useFeatureEnabled(
"captureAnchorAtMutationBoundary",
);
// 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
// restoration). useState would force re-renders we don't want.
Expand Down Expand Up @@ -314,13 +321,33 @@ export function useAnchoredScroll({
return;
}
}
if (captureAnchorAtMutationBoundary) {
const atBottom = isAtBottomNow(container);
anchorRef.current = atBottom ? { kind: "at-bottom" } : { kind: "away" };
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
return;
}

anchorRef.current = computeAnchor(container);
const atBottom = anchorRef.current.kind === "at-bottom";
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
if (atBottom) {
setNewMessageCount(0);
}
}, [scrollContainerRef]);
}, [captureAnchorAtMutationBoundary, scrollContainerRef]);

// Experimental path: scrolling only tracks the cheap bottom/away state.
// Pay for the exact row geometry once, immediately before a history fetch
// can prepend rows, instead of forcing a full row walk on every scroll event.
const captureAnchorBeforePrepend = React.useCallback(() => {
if (!captureAnchorAtMutationBoundary) return;
const container = scrollContainerRef.current;
if (!container || isAtBottomNow(container)) return;
anchorRef.current = computeAnchor(container);
}, [captureAnchorAtMutationBoundary, scrollContainerRef]);

// ---------------------------------------------------------------------------
// Anchor restoration: after every render, stick to the bottom if the user is
Expand Down Expand Up @@ -410,7 +437,7 @@ export function useAnchoredScroll({
// Stick to bottom across the append.
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
if (newLatestArrived) setNewMessageCount(0);
} else if (messagesArrived > 0) {
} else if (anchor.kind === "message" && messagesArrived > 0) {
// Anchored mid-history. An older-history prepend grows the content above
// the reading row; the browser's native scroll anchoring does NOT correct
// this at the top edge (no anchor node above the viewport when scrollTop
Expand All @@ -435,6 +462,8 @@ export function useAnchoredScroll({
if (!isPrepend) {
setNewMessageCount((current) => current + messagesArrived);
}
} else if (anchor.kind === "away" && messagesArrived > 0 && !isPrepend) {
setNewMessageCount((current) => current + messagesArrived);
}

prevLastMessageIdRef.current = lastMessage?.id;
Expand Down Expand Up @@ -527,6 +556,7 @@ export function useAnchoredScroll({
}, []);

return {
captureAnchorBeforePrepend,
onScroll,
isAtBottom,
newMessageCount,
Expand Down
8 changes: 8 additions & 0 deletions preview-features.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
"desktop"
]
},
{
"id": "captureAnchorAtMutationBoundary",
"name": "Mutation-boundary timeline anchoring",
"description": "Avoid full message-row geometry scans during timeline scrolling",
"platforms": [
"desktop"
]
},
{
"id": "forum",
"name": "Forum Channels",
Expand Down
Loading