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);
|
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-события заморожены,
|
||||||
|
|||||||
@@ -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. Скроллим только если
|
||||||
// пользователь уже у низа списка или последнее сообщение — его собственное.
|
// пользователь уже у низа списка или последнее сообщение — его собственное.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user