fix(chat): настоящая первопричина дёрганья — remount чата из-за inline-компонента ChatSection + первый кадр не у низа

- TaskDetail: ChatSection объявлен inline в теле рендера → новый тип компонента
  на каждый рендер → React перемонтировал весь чат при ЛЮБОМ ре-рендере TaskDetail
  (SSE-событие, refetch задачи, возврат на вкладку): скролл сбрасывался вверх,
  пользователь видел «старые сообщения → прыжок к последним». Фикс: useCallback
  со стабильной идентичностью (тот же паттерн, что у мобильного accordion).
- TaskChat/useChatController: скролл к низу при открытии чата — useLayoutEffect
  СИНХРОННО ДО paint (первый кадр уже у низа) + докрутка первого кадра, когда
  сообщения пришли после монтирования без кэша.
This commit is contained in:
2026-09-21 21:33:26 +03:00
parent d0d73a59f2
commit 693834ac27
3 changed files with 48 additions and 8 deletions

View File

@@ -488,18 +488,36 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
return () => viewport.removeEventListener('scroll', onScroll); return () => viewport.removeEventListener('scroll', onScroll);
}, [taskId, isLoading]); }, [taskId, isLoading]);
// Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query) // Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query).
useEffect(() => { // useLayoutEffect: первый скролл — СИНХРОННО ДО paint, чтобы первый же кадр был
// у низа списка, а не вверху старой части (иначе пользователь видит «старые
// сообщения → прыжок к последним»). Отложенная коррекция остаётся — после
// измерения высот виртуализатором позиция может сместиться.
useLayoutEffect(() => {
if (!taskId) return; if (!taskId) return;
atBottomRef.current = true; atBottomRef.current = true;
lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене задачи lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене задачи
setNewBelowCount(0); setNewBelowCount(0);
scrolledToFirstBatchRef.current = false;
scrollToBottomRef.current(); // до paint — первый кадр уже у низа
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(() => scrollToBottomRef.current(), 150); setTimeout(() => scrollToBottomRef.current(), 150); // коррекция после measure
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]); }, [taskId]);
// Докрутка первого кадра, когда сообщения пришли ПОСЛЕ монтирования
// (нет кэша react-query и localStorage — первый визит в этот чат):
// до paint подменяем верх списка на низ.
const scrolledToFirstBatchRef = useRef(false);
useLayoutEffect(() => {
if (isLoading || messages.length === 0) return;
if (scrolledToFirstBatchRef.current) return;
scrolledToFirstBatchRef.current = true;
scrollToBottomRef.current();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading, messages.length]);
// Автоскролл при новых сообщениях: только если пользователь у низа списка // Автоскролл при новых сообщениях: только если пользователь у низа списка
// или последнее сообщение — его собственное (как в мессенджере). // или последнее сообщение — его собственное (как в мессенджере).
// ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены, // ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены,

View File

@@ -704,17 +704,33 @@ export function useChatController() {
// ── Auto-scroll ─────────────────────────────────────────────────────────────── // ── Auto-scroll ───────────────────────────────────────────────────────────────
// Scroll to bottom whenever the active conversation changes (open from cache, new chat, etc.) // Scroll to bottom whenever the active conversation changes (open from cache, new chat, etc.)
useEffect(() => { // useLayoutEffect: первый скролл — СИНХРОННО ДО paint, чтобы первый же кадр был
// у низа списка, а не вверху старой части (иначе «старые сообщения → прыжок»).
// Отложенная коррекция остаётся — после измерения высот виртуализатором.
useLayoutEffect(() => {
if (!activeConvId) return; if (!activeConvId) return;
atBottomRef.current = true; atBottomRef.current = true;
lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене диалога lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене диалога
setNewBelowCount(0); setNewBelowCount(0);
scrolledToFirstBatchRef.current = false;
scrollToBottom(); // до paint — первый кадр уже у низа
requestAnimationFrame(() => { requestAnimationFrame(() => {
setTimeout(scrollToBottom, 120); setTimeout(scrollToBottom, 120); // коррекция после measure
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConvId, scrollToBottom]); }, [activeConvId, scrollToBottom]);
// Докрутка первого кадра, когда сообщения пришли ПОСЛЕ монтирования
// (нет кэша react-query и localStorage — первый визит в этот диалог).
const scrolledToFirstBatchRef = useRef(false);
useLayoutEffect(() => {
if (msgsLoading || initialMessages.length === 0) return;
if (scrolledToFirstBatchRef.current) return;
scrolledToFirstBatchRef.current = true;
scrollToBottom();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [msgsLoading, initialMessages.length]);
// Scroll to bottom when new messages arrive (tracked via initialMessages.length), // Scroll to bottom when new messages arrive (tracked via initialMessages.length),
// NOT when old messages are prepended via load-more. Скроллим только если // NOT when old messages are prepended via load-more. Скроллим только если
// пользователь уже у низа списка или последнее сообщение — его собственное. // пользователь уже у низа списка или последнее сообщение — его собственное.

View File

@@ -575,14 +575,20 @@ const TaskDetail = () => {
formNamesMap[f.id] = f.name; formNamesMap[f.id] = f.name;
}); });
// Компонент чата (без заголовка — он вынесен в полосу вкладок) // Компонент чата (без заголовка — он вынесен в полосу вкладок).
const ChatSection = ({ className = '' }: { className?: string }) => ( // ВАЖНО: обязательно useCallback со стабильной идентичностью — компонент,
// объявленный inline в теле рендера, получает НОВЫЙ тип на каждый рендер,
// и React полностью перемонтирует чат (сброс скролла и состояния) при ЛЮБОМ
// ре-рендере TaskDetail (SSE-событие, refetch задачи, возврат на вкладку).
// Именно этот remount и был первопричиной «дёрганья» чата. Тот же паттерн
// уже исправлен для мобильного accordion (см. комментарий ниже).
const ChatSection = useCallback(({ className = '' }: { className?: string }) => (
<div className={`flex flex-col bg-background ${className}`}> <div className={`flex flex-col bg-background ${className}`}>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<TaskChat taskId={taskIdNumber} /> <TaskChat taskId={taskIdNumber} />
</div> </div>
</div> </div>
); ), [taskIdNumber]);
const taskContent = ( const taskContent = (
<TaskContent <TaskContent