From 12c66d676a46257c4cce7861f2bf356324a41aca Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 12 May 2026 22:58:07 +0200 Subject: [PATCH] fix(chat): use useLayoutEffect for scroll restore + auto-bottom to avoid mount flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll-position memory introduced in 0.17.2 still produced a visible "chat appears at the top then jumps" frame when switching back into a conversation. Cause: both scroll-affecting effects (auto-bottom on new messages, restore on chat re-entry) used useEffect, which fires AFTER the browser paints the freshly-committed DOM. So users saw scrollTop=0 for one frame before the effect ran and corrected it. Switching both to useLayoutEffect moves the scroll write into the same commit phase as the message-list DOM update, so the very first paint already shows the correct position — single paint, no flicker. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/src/pages/ConversationPage.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 9d7351d..e0342f6 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -1,6 +1,6 @@ import { parseMessagePayload } from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; @@ -406,7 +406,14 @@ export function ConversationPage() { if (id && messages.length > 0) markRead(id); }, [id, messages.length, markRead]); - useEffect(() => { + // useLayoutEffect: run synchronously after DOM commit, before the + // browser paints. Using useEffect here let one frame of "scrollTop = 0 + // (top of list)" paint between message-list mount and the auto-scroll, + // which is exactly the "flickers to a different position, then jumps" + // glitch users saw when re-entering a chat. Layout-effect fires while + // the message list is in the DOM but before paint, so the first frame + // already shows the correct scroll position. + useLayoutEffect(() => { const el = scrollRef.current; if (!el || !stickToBottom) return; el.scrollTop = el.scrollHeight; @@ -423,7 +430,13 @@ export function ConversationPage() { const restoredForRef = useRef(null); const isRestoringRef = useRef(false); - useEffect(() => { + // useLayoutEffect, same reason as above: writing scrollTop here happens + // before the first paint of the freshly-mounted chat, so the user + // doesn't see a frame at scrollTop=0 before the jump to the saved + // position. Combined with the messages.length gate this means the + // re-entry shows the message list AT the saved scroll location in one + // single paint — no "loaded then jumped" effect. + useLayoutEffect(() => { const el = scrollRef.current; if (!el || !id) return; if (restoredForRef.current === id) return;