fix(chat): настоящая первопричина дёрганья — remount чата из-за inline-компонента ChatSection + первый кадр не у низа
- TaskDetail: ChatSection объявлен inline в теле рендера → новый тип компонента на каждый рендер → React перемонтировал весь чат при ЛЮБОМ ре-рендере TaskDetail (SSE-событие, refetch задачи, возврат на вкладку): скролл сбрасывался вверх, пользователь видел «старые сообщения → прыжок к последним». Фикс: useCallback со стабильной идентичностью (тот же паттерн, что у мобильного accordion). - TaskChat/useChatController: скролл к низу при открытии чата — useLayoutEffect СИНХРОННО ДО paint (первый кадр уже у низа) + докрутка первого кадра, когда сообщения пришли после монтирования без кэша.
This commit is contained in:
@@ -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-события заморожены,
|
||||
|
||||
@@ -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. Скроллим только если
|
||||
// пользователь уже у низа списка или последнее сообщение — его собственное.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user