[perf][ui] Фоновые ре-рендеры и дубли обработки: setter-подписки на treeDataAtom, socket-хендлеры без off, двойное обновление дерева, refetch-штормы и пустой кад… #344

Open
opened 2026-07-04 19:43:32 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

Даже вне редактора интерфейс «шуршит» фоновой работой: любое событие дерева (rename/move от коллеги по socket, drag, lazy-load) перерендеривает все видимые строки сайдбара, socket-события после reconnect обрабатываются по 2–3 раза, один rename перестраивает дерево дважды, навигация между страницами даёт пустой кадр и всплеск refetch'ей. По отдельности каждый пункт мелкий, но вместе они дают то самое ощущение «задумчивости то там, то там» на слабых машинах.

Важно: сама инфраструктура дерева уже хорошо оптимизирована — виртуализация (doc-tree.tsx#L209), сильный memo-компаратор (doc-tree-row.tsx#L360-L393), O(N)-утилиты на Map, точечные socket-редьюсеры. Проблемы — в подписках и дублях вокруг неё.

Диагноз (по убыванию вклада)

  1. Каждая видимая строка дерева подписана на весь treeDataAtom ради сеттера. space-tree-row.tsx#L54: const [, setTreeData] = useAtom(treeDataAtom) — jotai подписывает компонент на значение атома, и любое событие дерева ре-рендерит все ~20–30 виртуализированных строк напрямую, в обход memo-компаратора DocTreeRow. Каждая строка при этом заново гоняет useTranslation, useParams, buildPageUrl, пересоздаёт EmojiPicker/Tooltip-поддерево. Тот же анти-паттерн: use-tree-mutation.ts#L37 (ре-рендерит SpaceSidebar целиком), use-tree-socket.ts#L19 (дёргает UserProvider), value-подписки в space-tree-node-menu.tsx#L55 и mention-list.tsx#L58. Хлебные крошки (breadcrumb.tsx#L37) подписаны на весь атом и на каждое изменение дерева гоняют полный обход computeBreadcrumbState.

  2. Socket-хендлеры регистрируются без cleanup — после reconnect события обрабатываются кратно. use-query-subscription.ts#L22 (эффект с deps [queryClient, socket], cleanup нет) и use-tree-socket.ts#L39-L68 (deps [socket], cleanup нет). При пересоздании сокета / ре-ране эффекта хендлеры накапливаются: каждый broadcast → дублированные invalidateQueries/setQueryData/обходы дерева. Эталон рядом: use-notification-socket.ts#L18-L21 делает socket.off правильно. Это и перф-проблема, и просто баг (деградация растёт по ходу долгой сессии на нестабильной сети).

  3. Один rename обновляет дерево дважды + широкая инвалидация. Событие updateOne применяется точечно через applyUpdateOne (use-tree-socket), но параллельно invalidateOnUpdatePage зовёт invalidatePageTree() (инвалидация всего ["page-tree"]) и мутирует root-sidebar-pages → меняется reference → эффект space-tree.tsx#L93-L112 прогоняет полный buildTree + mergeRootTrees + второй setData. На каждый rename/icon-change любого пользователя спейса.

  4. usePageQuery без select → периодическая волна ре-рендеров при печати. page-query.ts#L46: ~11 подписчиков (header-меню, breadcrumbs, details, AI-чат, tabs комментариев и т.д.) читают title/permissions/id, но подписаны на весь объект страницы, включая content. Каждые ~3 с при печати (debouncedUpdateContent) и на каждый collab page.updated идентичность объекта меняется → ре-рендер всей периферии.

  5. Пустой кадр + waterfall при навигации. page.tsx#L47-L90: usePageQuery → затем useGetSpaceBySlugQuery (гейт по результату первой); при isLoading/!space рендерится пустой фрагмент вместо скелетона; placeholderData: keepPreviousData нет. Переход между страницами = вспышка пустоты (hover-префетч в строках дерева смягчает, но не для переходов по ссылкам/поиску).

  6. refetchOnMount: true перебивает разумный глобальный дефолт (staleTime 5 мин, refetchOnMount false в main.tsx#L26-L35): favorite-query.ts#L27,L35 (потребляется каждым NodeMenu строки дерева), page-query.ts#L354,L370,L383, space-query.ts#L41, space-watcher-query.ts#L19, workspace-query.ts#L246 — всплеск сетевых запросов и ре-рендеров на каждую навигацию, при том что socket/мутации и так поддерживают кэш свежим.

Мелкое: сайдбар не рисует серверное дерево, пока не выкачает все корневые страницы по 100 (space-tree.tsx#L87-L94) — на больших спейсах с холодным кэшем задержка первого кадра; глобальные mousemove/mouseup для ресайза сайдбара висят постоянно (global-app-shell.tsx#L69-L77) — вешать на mousedown; per-row EmojiPicker вешает глобальный keydown (emoji-picker.tsx#L54-L67) — гейтить по opened; AiChatWindow гоняет свои запросы даже закрытым (global-app-shell.tsx#L169) — enabled: isOpen (его lazy-загрузка — в #342).

Проверено и уже правильно (не трогать): виртуализация и memo дерева; O(N)-утилиты buildTree/mergeRootTrees/reconcileChildren; точечные socket-редьюсеры; отсутствие polling'а; debounce+size-guard записи дерева в localStorage; spotlight с дебаунсом и enabled; пагинация списков home; тривиальный axios-интерцептор; чистый CSS (без transition: all и тяжёлых эффектов на скролл-контейнерах).

Границы изменения

Только apps/client: атомы/подписки, websocket-хуки, query-хуки, page.tsx. Серверная часть, API, схема данных, MCP — не затрагиваются. Поведение фич 1:1.

Решение

  1. useAtom(treeDataAtom)useSetAtom в setter-only местах (space-tree-row, use-tree-mutation, use-tree-socket) — механическая замена, убирает лавину ре-рендеров:
// before: subscribes the row to the WHOLE tree atom value
const [, setTreeData] = useAtom(treeDataAtom);
// after: setter-only, no value subscription, no re-render on tree updates
const setTreeData = useSetAtom(treeDataAtom);

Для breadcrumbs — узкий срез через selectAtom (цепочка предков текущей страницы) либо переход на usePageBreadcrumbsQuery; для node-menu/mention-list — useAtomValue только там, где значение реально нужно, или срез.

  1. Cleanup socket-хендлеров: именованные хендлеры + return () => socket.off("message", handler) в обоих хуках; в идеале — один диспетчер message вместо двух параллельных подписчиков (query-кэш и дерево) с роутингом по operation.
  2. Убрать двойной путь обновления дерева: для простых field-обновлений (updateOne) полагаться на точечный атомный апдейт; из invalidateOnUpdatePage убрать безусловный invalidatePageTree() (скоупить до реально затронутого spaceId/поддерева) и не трогать root-sidebar-pages, когда изменение уже применено точечно; в эффекте space-tree — short-circuit при неизменном результате buildTree.
  3. select для периферийных подписчиков usePageQuery: вариант хука usePageMetaQuery с select: p => ({ id, slugId, title, icon, permissions, spaceId }) (стабильная идентичность через structural sharing react-query) — перевести на него header/breadcrumbs/details/AI-чат; полный объект остаётся только там, где нужен content.
  4. Навигация: placeholderData: keepPreviousData в usePageQuery + скелетон вместо пустых фрагментов в page.tsx; не гейтить рендер редактора на space-query (она нужна только для второстепенных данных — брать из кэша с fallback).
  5. Ревизия refetchOnMount: true: убрать там, где кэш поддерживается socket/мутациями (favorites — точно: мутации уже делают setQueriesData); где реально нужна свежесть — умеренный staleTime вместо принудительного refetch.
  6. Мелочь из диагноза: mousemove по mousedown, EmojiPicker keydown по opened, enabled: isOpen для запросов AI-чата, инкрементальный рендер дерева по мере догрузки корневых страниц.

Крайние случаи

  • useSetAtom-замены — проверить, что ни один из трёх компонентов не читает значение атома неявно (по коду — не читают, только сеттер).
  • Единый socket-диспетчер — сохранить порядок применения (сначала query-кэш, потом дерево или наоборот) идентичным текущему, иначе возможны кадры рассинхрона крошек/дерева.
  • Скоупинг invalidatePageTree — не сломать сценарии move между спейсами и восстановление из trash (там полная инвалидация оправдана — оставить точечно для этих операций).
  • keepPreviousData — при показе предыдущей страницы во время загрузки новой не должно быть «мигания» чужого заголовка в title/крошках: связать с skeleton-состоянием заголовка.
  • structural sharing и select — убедиться, что select-проекция не пересоздаёт объект при неизменных полях (react-query сравнивает результат select по ссылке после structural sharing — компаратор по умолчанию достаточен).
  • Reconnect-реконсиляция дерева (space-tree.tsx#L224-L277) — намеренно полная, не трогаем.

Тесты / проверка

  • Юнит: редьюсеры/хуки socket — после двух подключений/отключений сокета хендлер вызывается ровно один раз на событие.
  • Юнит: usePageMetaQuery — запись content в кэш не меняет идентичность результата select.
  • Существующие тесты дерева/tree-model — прогнать без изменений (логика не меняется).
  • Ручная проверка: React DevTools Profiler — rename страницы коллегой ре-рендерит одну строку, а не все видимые; переключение страниц не даёт пустого кадра; Network-панель — навигация не порождает всплеск повторных GET.

Вне скоупа

  • Панель комментариев (TipTap на каждый комментарий, keepMounted, мемоизация списка) — полностью покрыто #340.
  • Бандл и lazy-загрузка — #342 (слой A).
  • Латентность печати в редакторе — #343 (слой B).
  • Виртуализация таблиц настроек (members/groups/spaces) — заметно только на очень больших воркспейсах, отдельный follow-up при необходимости.

План работ

  1. useSetAtom-замены + cleanup socket-хендлеров (пп. 1–2) — дёшево, сразу убирает главные лавины и накопительный баг.
  2. Двойное обновление дерева и скоупинг инвалидаций (п. 3).
  3. usePageMetaQuery + перевод периферии (п. 4).
  4. keepPreviousData + скелетоны навигации (п. 5).
  5. Ревизия refetchOnMount (п. 6).
  6. Мелочь (п. 7).
  7. Профилирование до/после (Profiler + Network) на сценариях: чужой rename, своя печать 1 мин, 10 навигаций подряд.
# Суть Даже вне редактора интерфейс «шуршит» фоновой работой: любое событие дерева (rename/move от коллеги по socket, drag, lazy-load) перерендеривает все видимые строки сайдбара, socket-события после reconnect обрабатываются по 2–3 раза, один rename перестраивает дерево дважды, навигация между страницами даёт пустой кадр и всплеск refetch'ей. По отдельности каждый пункт мелкий, но вместе они дают то самое ощущение «задумчивости то там, то там» на слабых машинах. Важно: сама инфраструктура дерева уже хорошо оптимизирована — виртуализация ([doc-tree.tsx#L209](apps/client/src/features/page/tree/components/doc-tree.tsx#L209)), сильный memo-компаратор ([doc-tree-row.tsx#L360-L393](apps/client/src/features/page/tree/components/doc-tree-row.tsx#L360-L393)), O(N)-утилиты на Map, точечные socket-редьюсеры. Проблемы — в подписках и дублях **вокруг** неё. # Диагноз (по убыванию вклада) 1. **Каждая видимая строка дерева подписана на весь `treeDataAtom` ради сеттера.** [space-tree-row.tsx#L54](apps/client/src/features/page/tree/components/space-tree-row.tsx#L54): `const [, setTreeData] = useAtom(treeDataAtom)` — jotai подписывает компонент на **значение** атома, и любое событие дерева ре-рендерит все ~20–30 виртуализированных строк напрямую, **в обход** memo-компаратора `DocTreeRow`. Каждая строка при этом заново гоняет `useTranslation`, `useParams`, `buildPageUrl`, пересоздаёт EmojiPicker/Tooltip-поддерево. Тот же анти-паттерн: [use-tree-mutation.ts#L37](apps/client/src/features/page/tree/hooks/use-tree-mutation.ts#L37) (ре-рендерит `SpaceSidebar` целиком), [use-tree-socket.ts#L19](apps/client/src/features/websocket/use-tree-socket.ts#L19) (дёргает `UserProvider`), value-подписки в [space-tree-node-menu.tsx#L55](apps/client/src/features/page/tree/components/space-tree-node-menu.tsx#L55) и [mention-list.tsx#L58](apps/client/src/features/editor/components/mention/mention-list.tsx#L58). Хлебные крошки ([breadcrumb.tsx#L37](apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx#L37)) подписаны на весь атом и на каждое изменение дерева гоняют полный обход `computeBreadcrumbState`. 2. **Socket-хендлеры регистрируются без cleanup — после reconnect события обрабатываются кратно.** [use-query-subscription.ts#L22](apps/client/src/features/websocket/use-query-subscription.ts#L22) (эффект с deps `[queryClient, socket]`, cleanup **нет**) и [use-tree-socket.ts#L39-L68](apps/client/src/features/websocket/use-tree-socket.ts#L39-L68) (deps `[socket]`, cleanup **нет**). При пересоздании сокета / ре-ране эффекта хендлеры накапливаются: каждый broadcast → дублированные `invalidateQueries`/`setQueryData`/обходы дерева. Эталон рядом: [use-notification-socket.ts#L18-L21](apps/client/src/features/websocket/use-notification-socket.ts#L18-L21) делает `socket.off` правильно. Это и перф-проблема, и просто **баг** (деградация растёт по ходу долгой сессии на нестабильной сети). 3. **Один rename обновляет дерево дважды + широкая инвалидация.** Событие `updateOne` применяется точечно через `applyUpdateOne` (use-tree-socket), но параллельно [invalidateOnUpdatePage](apps/client/src/features/page/queries/page-query.ts#L512-L543) зовёт `invalidatePageTree()` (инвалидация **всего** `["page-tree"]`) и мутирует `root-sidebar-pages` → меняется reference → эффект [space-tree.tsx#L93-L112](apps/client/src/features/page/tree/components/space-tree.tsx#L93-L112) прогоняет полный `buildTree` + `mergeRootTrees` + второй `setData`. На каждый rename/icon-change любого пользователя спейса. 4. **`usePageQuery` без `select` → периодическая волна ре-рендеров при печати.** [page-query.ts#L46](apps/client/src/features/page/queries/page-query.ts#L46): ~11 подписчиков (header-меню, breadcrumbs, details, AI-чат, tabs комментариев и т.д.) читают `title`/`permissions`/`id`, но подписаны на весь объект страницы, включая `content`. Каждые ~3 с при печати (`debouncedUpdateContent`) и на каждый collab `page.updated` идентичность объекта меняется → ре-рендер всей периферии. 5. **Пустой кадр + waterfall при навигации.** [page.tsx#L47-L90](apps/client/src/pages/page/page.tsx#L47-L90): `usePageQuery` → затем `useGetSpaceBySlugQuery` (гейт по результату первой); при `isLoading`/`!space` рендерится пустой фрагмент вместо скелетона; `placeholderData: keepPreviousData` нет. Переход между страницами = вспышка пустоты (hover-префетч в строках дерева смягчает, но не для переходов по ссылкам/поиску). 6. **`refetchOnMount: true` перебивает разумный глобальный дефолт** (staleTime 5 мин, refetchOnMount false в [main.tsx#L26-L35](apps/client/src/main.tsx#L26-L35)): [favorite-query.ts#L27,L35](apps/client/src/features/favorite/queries/favorite-query.ts#L27) (потребляется каждым NodeMenu строки дерева), [page-query.ts#L354,L370,L383](apps/client/src/features/page/queries/page-query.ts#L354), [space-query.ts#L41](apps/client/src/features/space/queries/space-query.ts#L41), [space-watcher-query.ts#L19](apps/client/src/features/space/queries/space-watcher-query.ts#L19), [workspace-query.ts#L246](apps/client/src/features/workspace/queries/workspace-query.ts#L246) — всплеск сетевых запросов и ре-рендеров на каждую навигацию, при том что socket/мутации и так поддерживают кэш свежим. Мелкое: сайдбар не рисует серверное дерево, пока не выкачает **все** корневые страницы по 100 ([space-tree.tsx#L87-L94](apps/client/src/features/page/tree/components/space-tree.tsx#L87-L94)) — на больших спейсах с холодным кэшем задержка первого кадра; глобальные `mousemove`/`mouseup` для ресайза сайдбара висят постоянно ([global-app-shell.tsx#L69-L77](apps/client/src/components/layouts/global/global-app-shell.tsx#L69-L77)) — вешать на mousedown; per-row `EmojiPicker` вешает глобальный keydown ([emoji-picker.tsx#L54-L67](apps/client/src/components/ui/emoji-picker.tsx#L54-L67)) — гейтить по `opened`; `AiChatWindow` гоняет свои запросы даже закрытым ([global-app-shell.tsx#L169](apps/client/src/components/layouts/global/global-app-shell.tsx#L169)) — `enabled: isOpen` (его lazy-загрузка — в #342). **Проверено и уже правильно (не трогать):** виртуализация и memo дерева; O(N)-утилиты `buildTree`/`mergeRootTrees`/`reconcileChildren`; точечные socket-редьюсеры; отсутствие polling'а; debounce+size-guard записи дерева в localStorage; spotlight с дебаунсом и `enabled`; пагинация списков home; тривиальный axios-интерцептор; чистый CSS (без `transition: all` и тяжёлых эффектов на скролл-контейнерах). # Границы изменения Только `apps/client`: атомы/подписки, websocket-хуки, query-хуки, page.tsx. Серверная часть, API, схема данных, MCP — не затрагиваются. Поведение фич 1:1. # Решение 1. **`useAtom(treeDataAtom)` → `useSetAtom`** в setter-only местах (space-tree-row, use-tree-mutation, use-tree-socket) — механическая замена, убирает лавину ре-рендеров: ```tsx // before: subscribes the row to the WHOLE tree atom value const [, setTreeData] = useAtom(treeDataAtom); // after: setter-only, no value subscription, no re-render on tree updates const setTreeData = useSetAtom(treeDataAtom); ``` Для breadcrumbs — узкий срез через `selectAtom` (цепочка предков текущей страницы) либо переход на `usePageBreadcrumbsQuery`; для node-menu/mention-list — `useAtomValue` только там, где значение реально нужно, или срез. 2. **Cleanup socket-хендлеров**: именованные хендлеры + `return () => socket.off("message", handler)` в обоих хуках; в идеале — один диспетчер `message` вместо двух параллельных подписчиков (query-кэш и дерево) с роутингом по `operation`. 3. **Убрать двойной путь обновления дерева**: для простых field-обновлений (`updateOne`) полагаться на точечный атомный апдейт; из `invalidateOnUpdatePage` убрать безусловный `invalidatePageTree()` (скоупить до реально затронутого `spaceId`/поддерева) и не трогать `root-sidebar-pages`, когда изменение уже применено точечно; в эффекте space-tree — short-circuit при неизменном результате `buildTree`. 4. **`select` для периферийных подписчиков `usePageQuery`**: вариант хука `usePageMetaQuery` с `select: p => ({ id, slugId, title, icon, permissions, spaceId })` (стабильная идентичность через structural sharing react-query) — перевести на него header/breadcrumbs/details/AI-чат; полный объект остаётся только там, где нужен `content`. 5. **Навигация**: `placeholderData: keepPreviousData` в `usePageQuery` + скелетон вместо пустых фрагментов в page.tsx; не гейтить рендер редактора на space-query (она нужна только для второстепенных данных — брать из кэша с fallback). 6. **Ревизия `refetchOnMount: true`**: убрать там, где кэш поддерживается socket/мутациями (favorites — точно: мутации уже делают `setQueriesData`); где реально нужна свежесть — умеренный `staleTime` вместо принудительного refetch. 7. Мелочь из диагноза: mousemove по mousedown, EmojiPicker keydown по `opened`, `enabled: isOpen` для запросов AI-чата, инкрементальный рендер дерева по мере догрузки корневых страниц. # Крайние случаи - **useSetAtom-замены** — проверить, что ни один из трёх компонентов не читает значение атома неявно (по коду — не читают, только сеттер). - **Единый socket-диспетчер** — сохранить порядок применения (сначала query-кэш, потом дерево или наоборот) идентичным текущему, иначе возможны кадры рассинхрона крошек/дерева. - **Скоупинг `invalidatePageTree`** — не сломать сценарии move между спейсами и восстановление из trash (там полная инвалидация оправдана — оставить точечно для этих операций). - **`keepPreviousData`** — при показе предыдущей страницы во время загрузки новой не должно быть «мигания» чужого заголовка в title/крошках: связать с skeleton-состоянием заголовка. - **structural sharing и `select`** — убедиться, что select-проекция не пересоздаёт объект при неизменных полях (react-query сравнивает результат select по ссылке после structural sharing — компаратор по умолчанию достаточен). - **Reconnect-реконсиляция дерева** ([space-tree.tsx#L224-L277](apps/client/src/features/page/tree/components/space-tree.tsx#L224-L277)) — намеренно полная, не трогаем. # Тесты / проверка - Юнит: редьюсеры/хуки socket — после двух подключений/отключений сокета хендлер вызывается ровно один раз на событие. - Юнит: `usePageMetaQuery` — запись `content` в кэш не меняет идентичность результата select. - Существующие тесты дерева/tree-model — прогнать без изменений (логика не меняется). - Ручная проверка: React DevTools Profiler — rename страницы коллегой ре-рендерит одну строку, а не все видимые; переключение страниц не даёт пустого кадра; Network-панель — навигация не порождает всплеск повторных GET. # Вне скоупа - Панель комментариев (TipTap на каждый комментарий, keepMounted, мемоизация списка) — полностью покрыто #340. - Бандл и lazy-загрузка — #342 (слой A). - Латентность печати в редакторе — #343 (слой B). - Виртуализация таблиц настроек (members/groups/spaces) — заметно только на очень больших воркспейсах, отдельный follow-up при необходимости. # План работ 1. `useSetAtom`-замены + cleanup socket-хендлеров (пп. 1–2) — дёшево, сразу убирает главные лавины и накопительный баг. 2. Двойное обновление дерева и скоупинг инвалидаций (п. 3). 3. `usePageMetaQuery` + перевод периферии (п. 4). 4. `keepPreviousData` + скелетоны навигации (п. 5). 5. Ревизия `refetchOnMount` (п. 6). 6. Мелочь (п. 7). 7. Профилирование до/после (Profiler + Network) на сценариях: чужой rename, своя печать 1 мин, 10 навигаций подряд.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#344