[perf][ui] Панель комментариев тормозит при открытии и при resolve/apply: TipTap-инстанс на каждый комментарий, обе вкладки смонтированы, полный перере… #340
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?
Суть
На странице с большим числом комментариев (реальный кейс: 30 open + 326 resolved ≈ 356 тредов) панель комментариев подвисает на секунды при открытии и заметно подтормаживает на каждом resolve и Apply предложения. Причина не в сервере и не в данных — это чисто клиентская стоимость рендера: на каждый комментарий создаётся полноценный TipTap/ProseMirror-редактор, обе вкладки смонтированы одновременно, а любая мутация перерендеривает весь список целиком.
Диагноз (по убыванию вклада)
TipTap-инстанс на каждый комментарий (главное). Read-only тело комментария рендерится через
CommentEditor— comment-list-item.tsx#L307, который создаётuseEditorсо StarterKit + Mention (React node views) + EmojiCommand + Link, причёмimmediatelyRender: true— comment-editor.tsx#L41-L109. На 356 тредов с ответами это ~400+ инстансов ProseMirror, создаваемых синхронно при монтировании панели.Обе вкладки смонтированы одновременно. У Mantine 8
Tabsпо умолчаниюkeepMounted: true— comment-list-with-tabs.tsx#L203. Все 326 resolved-комментариев монтируются (со своими TipTap-инстансами), даже когда видна только вкладка Open.Жадные редакторы ответа. Под каждым открытым тредом сразу монтируется редактируемый TipTap-редактор «Reply...» — comment-list-with-tabs.tsx#L155-L164. Ещё ~30 тяжёлых инстансов, из которых в момент времени используется максимум один.
Полный перерендер списка на любую мутацию (тормоза resolve/apply). Мутации resolve/apply обновляют кэш react-query → новый identity объекта
comments→ колбэкrenderCommentsпересоздаётся (в depscommentsиisLoading) — comment-list-with-tabs.tsx#L126-L175.CommentListItemне мемоизирован → все ~356 элементов перерендериваются, каждый тянетEditorContent,useHover, аватары. То же самое при отправке ответа (isLoadingв deps). При этом сам кэш обновляется аккуратно:updateCommentInCacheсохраняет identity нетронутых комментариев — comment-query.ts#L164-L178, т.е. основа для мемоизации уже есть.Вторичное (вклад в «тормозит при открытии», но меньше рендера):
useCommentsQueryгрузит страницы по 100 последовательно (сервер:@Max(100)— pagination-options.ts#L16) и не отдаёт ничего до полной загрузки (isLoading: query.isLoading || query.hasNextPage— comment-query.ts#L54). На 356 комментариев это 4+ последовательных round-trip до первого кадра.Транзакция
setCommentResolvedв основном редакторе (полный обход дока — comment.ts#L137-L164) — необходимая и одиночная, её не трогаем.Границы изменения
Чисто клиентское (apps/client, feature comment + извлечение презентационной части mention). Ничего не меняется в БД, API, MCP,
IComment, серверной логике resolve/apply. Поведение фич сохраняется 1:1 — меняется только способ рендера и гранулярность перерендеров.Решение
1. Статический рендер read-only контента (главный выигрыш)
Новый компонент
CommentContentView(features/comment/components/comment-content-view.tsx) — рекурсивный React-рендерер по ProseMirror JSON без инстанса редактора:doc,paragraph,text,hardBreak,mention. Марки:bold,italic,strike,code,link— ровно тот набор расширений, что подключён вCommentEditor, других узлов в комментариях быть не может.MentionContent({ attrs })(user-mention — span, page-mention — react-routerLinkcusePageQuery, как сейчас), использовать его и в TipTap NodeView, и в статическом рендерере. Запросы react-query дедуплицируются, меншены в комментариях редки — паритет поведения без массовых запросов.CommentEditor(плюсconsole.warnв dev). Прецедент «не-TipTap» обработки контента уже есть: comment-content-to-text.ts (hover-preview). Учесть legacy-случай «content — не-JSON строка» → рендер как plain text.classes.commentEditor/ProseMirror-классы (comment.module.css), чтобы CSS совпал пиксель-в-пиксель.!isEditingв comment-list-item.tsx#L306-L307 наCommentContentView; ветка редактирования остаётся на TipTap. ЛокальныйuseState(content)+useEffectсинхронизации (L72-L74) для отображения больше не нужны — статический рендер просто берётcomment.contentиз пропсов (websocket-обновления начинают работать «бесплатно», безsetContentв чужой инстанс).2.
keepMounted={false}для вкладок<Tabs keepMounted={false}>в comment-list-with-tabs.tsx#L203 — неактивная вкладка не монтируется вовсе. Открытие панели монтирует 30 элементов вместо 356. Побочный эффект: позиция общего скролла при переключении вкладок сбрасывается — приемлемо (списки разные).3. Ленивый редактор ответа
В
CommentEditorWithActions(comment-list-with-tabs.tsx#L360-L388) изначально рендерить лёгкую заглушку — div в стилях поля ввода с текстом «Reply...» (role="button",tabIndex=0); настоящийCommentEditorмонтируется по клику/фокусу сautofocus. После первого монтирования не размонтировать (не терять черновик). НижнийPageCommentInput— один инстанс, остаётся жадным.4. Мемоизация: resolve/apply перерисовывает только затронутый тред
CommentListItem→React.memo.commentsвChildComments— предвычисленнаяMap childrenByParent(useMemoотcomments.items, один проход вместо текущего O(n²)-фильтра на каждый тред). Дочерний список передавать массивом; компараторmemo— по длине и ссылкам элементов (identity нетронутых комментариев кэш сохраняет, см. диагноз п. 4).isLoadingиз depsrenderComments: состояние отправки ответа перенести внутрьCommentEditorWithActions(локальный state /mutation.isPending).createCommentMutation.mutateAsync(стабильная ссылка), а не от объекта мутации.Итог: resolve/apply → перерендер одного элемента + дешёвое перестроение массивов вкладок; перемещение треда Open→Resolved остаётся анимационно мгновенным.
Вторичное (опционально, в этом же PR или follow-up)
|| query.hasNextPageизisLoadingв comment-query.ts#L54 — показывать первую сотню сразу, дозагружая остальные в фоне (счётчики на вкладках растут по мере загрузки — приемлемо).@tanstack/react-virtual(уже используется в doc-tree.tsx) — вне скоупа v1: после пп. 1–4 статический список из сотен элементов дёшев; вернуться, если появятся страницы с тысячами комментариев. Учесть, что виртуализация ломаетscrollIntoView-якоря (data-comment-id).Крайние случаи
useEffect setContentбольше не нужна.isEditingмонтирует TipTap по требованию, как сейчас; после сохранения возврат к статическому рендеру.commentпо ссылке и дочерний массив поэлементно; оптимистичный resolve и server-authoritative onSuccess оба меняют ссылку только затронутого комментария.keepMounted={false}.Тесты
CommentContentView: параграфы + марки (bold/italic/strike/code/link),hardBreak, mention (user/page), legacy plain-string, неизвестный узел → fallback-ветка.Вне скоупа v1
@Max(100)пагинации.План работ
CommentContentView+ извлечениеMentionContentизMentionView+ fallback на read-onlyCommentEditor.comment-list-item.tsxна статический рендер, чисткаsetContent-синхронизации.keepMounted={false}наTabs.CommentEditorWithActions.React.memo(CommentListItem),childrenByParent-Map, локализацияisLoading, стабильные колбэки.useCommentsQuery.