From 693834ac2771b04cb41dda2a349fb6fd61c4f6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Mon, 21 Sep 2026 21:33:26 +0300 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=D0=BD=D0=B0=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D1=89=D0=B0=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D0=B0=20=D0=B4=D1=91=D1=80=D0=B3?= =?UTF-8?q?=D0=B0=D0=BD=D1=8C=D1=8F=20=E2=80=94=20remount=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B8=D0=B7-=D0=B7=D0=B0=20inline-=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B0=20ChatSection?= =?UTF-8?q?=20+=20=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=20=D0=BD=D0=B5=20=D1=83=20=D0=BD=D0=B8=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TaskDetail: ChatSection объявлен inline в теле рендера → новый тип компонента на каждый рендер → React перемонтировал весь чат при ЛЮБОМ ре-рендере TaskDetail (SSE-событие, refetch задачи, возврат на вкладку): скролл сбрасывался вверх, пользователь видел «старые сообщения → прыжок к последним». Фикс: useCallback со стабильной идентичностью (тот же паттерн, что у мобильного accordion). - TaskChat/useChatController: скролл к низу при открытии чата — useLayoutEffect СИНХРОННО ДО paint (первый кадр уже у низа) + докрутка первого кадра, когда сообщения пришли после монтирования без кэша. --- client/src/components/TaskChat.tsx | 24 ++++++++++++++++--- .../src/components/chat/useChatController.ts | 20 ++++++++++++++-- client/src/pages/TaskDetail.tsx | 12 +++++++--- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index 9630011..94b8dd5 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -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-события заморожены, diff --git a/client/src/components/chat/useChatController.ts b/client/src/components/chat/useChatController.ts index 0d415d1..2cc0b1b 100644 --- a/client/src/components/chat/useChatController.ts +++ b/client/src/components/chat/useChatController.ts @@ -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. Скроллим только если // пользователь уже у низа списка или последнее сообщение — его собственное. diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 718697a..b5a1849 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -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 }) => (
- ); + ), [taskIdNumber]); const taskContent = (