From 02efffc956124cde3d8084cad8c8d911fa1c3677 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 18 Jun 2026 14:34:14 -0400 Subject: [PATCH] fix(desktop): restore anchor when fetch-older spinner toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The older-history fetch spinner renders above the anchor row but toggles on its own render commit (messages unchanged). The anchor-restoration layout effect was keyed only on `messages`, so it never re-ran when the spinner appeared or disappeared — leaving the spinner's height as an uncorrected shift above the reader's eye, the residual flicker on prepend. Thread isFetchingOlder into useAnchoredScroll as an extra restoration trigger so the existing scrollBy correction fires on the spinner toggle too, making the anchor the single owner of every layout change above the fold. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src/features/messages/ui/MessageTimeline.tsx | 1 + desktop/src/features/messages/ui/useAnchoredScroll.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 2ef6d453d..f9d7d1716 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -215,6 +215,7 @@ const MessageTimelineBase = React.forwardRef< contentRef, fetchOlder, hasOlderMessages, + isFetchingOlder, isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index f3acdd935..adc65b344 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -34,6 +34,13 @@ type UseAnchoredScrollOptions = { * debouncing, and post-prepend scroll restoration via the anchor. */ fetchOlder?: () => Promise; hasOlderMessages?: boolean; + /** True while an older-history fetch is in flight. The fetch spinner renders + * above the anchor, so toggling it shifts every row below it. The spinner + * toggles on its own commit (no message change), so without this signal the + * restoration effect — keyed on `messages` — wouldn't re-run to correct the + * shift, leaving a visible one-frame jump. Threading it through makes the + * anchor the single owner of every layout change above the reader's eye. */ + isFetchingOlder?: boolean; /** When set, scroll to and highlight this message on mount and on change. */ targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; @@ -142,6 +149,7 @@ export function useAnchoredScroll({ messages, fetchOlder, hasOlderMessages = false, + isFetchingOlder = false, targetMessageId = null, onTargetReached, }: UseAnchoredScrollOptions): UseAnchoredScrollResult { @@ -272,6 +280,7 @@ export function useAnchoredScroll({ // before the render. This is the single mechanism for keeping scroll // stable across prepends, appends, image loads, embed expansions, etc. // --------------------------------------------------------------------------- + // biome-ignore lint/correctness/useExhaustiveDependencies: `isFetchingOlder` is an intentional re-run trigger, not a read — the fetch spinner renders above the anchor on its own commit (with `messages` unchanged), so we re-run restoration on its toggle to correct the spinner-induced shift via the existing anchor. React.useLayoutEffect(() => { const container = scrollContainerRef.current; if (!container) return; @@ -388,6 +397,7 @@ export function useAnchoredScroll({ prevLastMessageIdRef.current = lastMessage?.id; prevMessageCountRef.current = messages.length; }, [ + isFetchingOlder, isLoading, messages, onTargetReached,