[perf][ui] Фоновые ре-рендеры и дубли обработки: setter-подписки на treeDataAtom, socket-хендлеры без off, двойное обновление дерева, refetch-штормы и пустой кад… #344
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Суть
Даже вне редактора интерфейс «шуршит» фоновой работой: любое событие дерева (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-редьюсеры. Проблемы — в подписках и дублях вокруг неё.
Диагноз (по убыванию вклада)
Каждая видимая строка дерева подписана на весь
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.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правильно. Это и перф-проблема, и просто баг (деградация растёт по ходу долгой сессии на нестабильной сети).Один 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 любого пользователя спейса.usePageQueryбезselect→ периодическая волна ре-рендеров при печати. page-query.ts#L46: ~11 подписчиков (header-меню, breadcrumbs, details, AI-чат, tabs комментариев и т.д.) читаютtitle/permissions/id, но подписаны на весь объект страницы, включаяcontent. Каждые ~3 с при печати (debouncedUpdateContent) и на каждый collabpage.updatedидентичность объекта меняется → ре-рендер всей периферии.Пустой кадр + waterfall при навигации. page.tsx#L47-L90:
usePageQuery→ затемuseGetSpaceBySlugQuery(гейт по результату первой); приisLoading/!spaceрендерится пустой фрагмент вместо скелетона;placeholderData: keepPreviousDataнет. Переход между страницами = вспышка пустоты (hover-префетч в строках дерева смягчает, но не для переходов по ссылкам/поиску).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-rowEmojiPickerвешает глобальный 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.Решение
useAtom(treeDataAtom)→useSetAtomв setter-only местах (space-tree-row, use-tree-mutation, use-tree-socket) — механическая замена, убирает лавину ре-рендеров:Для breadcrumbs — узкий срез через
selectAtom(цепочка предков текущей страницы) либо переход наusePageBreadcrumbsQuery; для node-menu/mention-list —useAtomValueтолько там, где значение реально нужно, или срез.return () => socket.off("message", handler)в обоих хуках; в идеале — один диспетчерmessageвместо двух параллельных подписчиков (query-кэш и дерево) с роутингом поoperation.updateOne) полагаться на точечный атомный апдейт; изinvalidateOnUpdatePageубрать безусловныйinvalidatePageTree()(скоупить до реально затронутогоspaceId/поддерева) и не трогатьroot-sidebar-pages, когда изменение уже применено точечно; в эффекте space-tree — short-circuit при неизменном результатеbuildTree.selectдля периферийных подписчиковusePageQuery: вариант хукаusePageMetaQueryсselect: p => ({ id, slugId, title, icon, permissions, spaceId })(стабильная идентичность через structural sharing react-query) — перевести на него header/breadcrumbs/details/AI-чат; полный объект остаётся только там, где нуженcontent.placeholderData: keepPreviousDataвusePageQuery+ скелетон вместо пустых фрагментов в page.tsx; не гейтить рендер редактора на space-query (она нужна только для второстепенных данных — брать из кэша с fallback).refetchOnMount: true: убрать там, где кэш поддерживается socket/мутациями (favorites — точно: мутации уже делаютsetQueriesData); где реально нужна свежесть — умеренныйstaleTimeвместо принудительного refetch.opened,enabled: isOpenдля запросов AI-чата, инкрементальный рендер дерева по мере догрузки корневых страниц.Крайние случаи
invalidatePageTree— не сломать сценарии move между спейсами и восстановление из trash (там полная инвалидация оправдана — оставить точечно для этих операций).keepPreviousData— при показе предыдущей страницы во время загрузки новой не должно быть «мигания» чужого заголовка в title/крошках: связать с skeleton-состоянием заголовка.select— убедиться, что select-проекция не пересоздаёт объект при неизменных полях (react-query сравнивает результат select по ссылке после structural sharing — компаратор по умолчанию достаточен).Тесты / проверка
usePageMetaQuery— записьcontentв кэш не меняет идентичность результата select.Вне скоупа
План работ
useSetAtom-замены + cleanup socket-хендлеров (пп. 1–2) — дёшево, сразу убирает главные лавины и накопительный баг.usePageMetaQuery+ перевод периферии (п. 4).keepPreviousData+ скелетоны навигации (п. 5).refetchOnMount(п. 6).