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);
}, [taskId, isLoading]);
// Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query)
useEffect(() => {
// Автоскролл к последнему сообщению при открытии задачи (даже из кэша react-query).
// useLayoutEffect: первый скролл — СИНХРОННО ДО paint, чтобы первый же кадр был
// у низа списка, а не вверху старой части (иначе пользователь видит «старые
// сообщения → прыжок к последним»). Отложенная коррекция остаётся — после
// измерения высот виртуализатором позиция может сместиться.
useLayoutEffect(() => {
if (!taskId) return;
atBottomRef.current = true;
lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене задачи
setNewBelowCount(0);
scrolledToFirstBatchRef.current = false;
scrollToBottomRef.current(); // до paint — первый кадр уже у низа
requestAnimationFrame(() => {
setTimeout(() => scrollToBottomRef.current(), 150);
setTimeout(() => scrollToBottomRef.current(), 150); // коррекция после measure
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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-события заморожены,

View File

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

View File

@@ -575,14 +575,20 @@ const TaskDetail = () => {
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-1 overflow-hidden">
<TaskChat taskId={taskIdNumber} />
</div>
</div>
);
), [taskIdNumber]);
const taskContent = (
<TaskContent