[perf][ui] Панель комментариев тормозит при открытии и при resolve/apply: TipTap-инстанс на каждый комментарий, обе вкладки смонтированы, полный перере… #340

Closed
opened 2026-07-04 19:27:28 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

На странице с большим числом комментариев (реальный кейс: 30 open + 326 resolved ≈ 356 тредов) панель комментариев подвисает на секунды при открытии и заметно подтормаживает на каждом resolve и Apply предложения. Причина не в сервере и не в данных — это чисто клиентская стоимость рендера: на каждый комментарий создаётся полноценный TipTap/ProseMirror-редактор, обе вкладки смонтированы одновременно, а любая мутация перерендеривает весь список целиком.

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

  1. TipTap-инстанс на каждый комментарий (главное). Read-only тело комментария рендерится через CommentEditorcomment-list-item.tsx#L307, который создаёт useEditor со StarterKit + Mention (React node views) + EmojiCommand + Link, причём immediatelyRender: truecomment-editor.tsx#L41-L109. На 356 тредов с ответами это ~400+ инстансов ProseMirror, создаваемых синхронно при монтировании панели.

  2. Обе вкладки смонтированы одновременно. У Mantine 8 Tabs по умолчанию keepMounted: truecomment-list-with-tabs.tsx#L203. Все 326 resolved-комментариев монтируются (со своими TipTap-инстансами), даже когда видна только вкладка Open.

  3. Жадные редакторы ответа. Под каждым открытым тредом сразу монтируется редактируемый TipTap-редактор «Reply...» — comment-list-with-tabs.tsx#L155-L164. Ещё ~30 тяжёлых инстансов, из которых в момент времени используется максимум один.

  4. Полный перерендер списка на любую мутацию (тормоза resolve/apply). Мутации resolve/apply обновляют кэш react-query → новый identity объекта comments → колбэк renderComments пересоздаётся (в deps comments и 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.hasNextPagecomment-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, других узлов в комментариях быть не может.
  • Mention: извлечь из mention-view.tsx презентационную часть в общий компонент MentionContent({ attrs }) (user-mention — span, page-mention — react-router Link c usePageQuery, как сейчас), использовать его и в TipTap NodeView, и в статическом рендерере. Запросы react-query дедуплицируются, меншены в комментариях редки — паритет поведения без массовых запросов.
  • Fallback — предохранитель: встретился неизвестный тип узла или контент не парсится как ProseMirror JSON → рендерим этот конкретный комментарий по-старому через read-only 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 перерисовывает только затронутый тред

  • CommentListItemReact.memo.
  • Вместо прокидывания всего comments в ChildComments — предвычисленная Map childrenByParent (useMemo от comments.items, один проход вместо текущего O(n²)-фильтра на каждый тред). Дочерний список передавать массивом; компаратор memo — по длине и ссылкам элементов (identity нетронутых комментариев кэш сохраняет, см. диагноз п. 4).
  • Убрать isLoading из deps renderComments: состояние отправки ответа перенести внутрь 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).

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

  • Меншены/ссылки в комментариях — паритет: клик по page-mention навигирует, user-mention стилизован; ссылки открываются как раньше.
  • Legacy/битый контент — не-JSON строка → plain text; неизвестный узел → fallback на read-only TipTap (комментарий-одиночка не тащит за собой деградацию всей панели).
  • Websocket-обновление чужого комментария — статический рендер обновляется из пропсов; ветка useEffect setContent больше не нужна.
  • Редактирование — ветка isEditing монтирует TipTap по требованию, как сейчас; после сохранения возврат к статическому рендеру.
  • Черновик ответа — лениво смонтированный редактор не размонтируется при перерендерах треда.
  • Корректность мемоизации — компаратор сравнивает comment по ссылке и дочерний массив поэлементно; оптимистичный resolve и server-authoritative onSuccess оба меняют ссылку только затронутого комментария.
  • Пустые вкладки, переключение вкладок — заглушки «No open/resolved comments» работают как раньше; сброс скролла при переключении — осознанный трейд-офф keepMounted={false}.

Тесты

  • Render-тесты CommentContentView: параграфы + марки (bold/italic/strike/code/link), hardBreak, mention (user/page), legacy plain-string, неизвестный узел → fallback-ветка.
  • Адаптация comment-list-item.test.tsx: read-only ветка теперь статическая; suggestion-блок и Apply не затронуты.
  • Поведенческий смок: resolve перемещает тред между вкладками, reply-заглушка по клику превращается в редактор и сохраняет ввод.
  • Ручной перф-замер на реальной странице 350+ комментариев (Chrome DevTools Performance): время открытия панели до и после, время кадра на resolve/apply.

Вне скоупа v1

  • Виртуализация списков (см. «Вторичное»).
  • Поднятие серверного @Max(100) пагинации.
  • Любые изменения сервера/MCP/схемы данных.
  • hover-preview и inline-диалог комментария (там уже нет проблемы: plain-text и один editable-инстанс).

План работ

  1. CommentContentView + извлечение MentionContent из MentionView + fallback на read-only CommentEditor.
  2. Замена read-only ветки в comment-list-item.tsx на статический рендер, чистка setContent-синхронизации.
  3. keepMounted={false} на Tabs.
  4. Ленивая заглушка reply-редактора в CommentEditorWithActions.
  5. Мемоизация: React.memo(CommentListItem), childrenByParent-Map, локализация isLoading, стабильные колбэки.
  6. (Опция) прогрессивный первый кадр в useCommentsQuery.
  7. Тесты + ручной перф-замер до/после.
# Суть На странице с большим числом комментариев (реальный кейс: **30 open + 326 resolved ≈ 356 тредов**) панель комментариев подвисает на секунды при открытии и заметно подтормаживает на каждом resolve и Apply предложения. Причина не в сервере и не в данных — это чисто клиентская стоимость рендера: **на каждый комментарий создаётся полноценный TipTap/ProseMirror-редактор**, обе вкладки смонтированы одновременно, а любая мутация перерендеривает весь список целиком. # Диагноз (по убыванию вклада) 1. **TipTap-инстанс на каждый комментарий (главное).** Read-only тело комментария рендерится через `CommentEditor` — [comment-list-item.tsx#L307](apps/client/src/features/comment/components/comment-list-item.tsx#L307), который создаёт `useEditor` со StarterKit + Mention (React node views) + EmojiCommand + Link, причём `immediatelyRender: true` — [comment-editor.tsx#L41-L109](apps/client/src/features/comment/components/comment-editor.tsx#L41-L109). На 356 тредов с ответами это **~400+ инстансов ProseMirror, создаваемых синхронно при монтировании панели**. 2. **Обе вкладки смонтированы одновременно.** У Mantine 8 `Tabs` по умолчанию `keepMounted: true` — [comment-list-with-tabs.tsx#L203](apps/client/src/features/comment/components/comment-list-with-tabs.tsx#L203). Все 326 resolved-комментариев монтируются (со своими TipTap-инстансами), даже когда видна только вкладка Open. 3. **Жадные редакторы ответа.** Под каждым открытым тредом сразу монтируется **редактируемый** TipTap-редактор «Reply...» — [comment-list-with-tabs.tsx#L155-L164](apps/client/src/features/comment/components/comment-list-with-tabs.tsx#L155-L164). Ещё ~30 тяжёлых инстансов, из которых в момент времени используется максимум один. 4. **Полный перерендер списка на любую мутацию (тормоза resolve/apply).** Мутации resolve/apply обновляют кэш react-query → новый identity объекта `comments` → колбэк `renderComments` пересоздаётся (в deps `comments` и `isLoading`) — [comment-list-with-tabs.tsx#L126-L175](apps/client/src/features/comment/components/comment-list-with-tabs.tsx#L126-L175). `CommentListItem` не мемоизирован → **все ~356 элементов перерендериваются**, каждый тянет `EditorContent`, `useHover`, аватары. То же самое при отправке ответа (`isLoading` в deps). При этом сам кэш обновляется аккуратно: `updateCommentInCache` сохраняет identity нетронутых комментариев — [comment-query.ts#L164-L178](apps/client/src/features/comment/queries/comment-query.ts#L164-L178), т.е. основа для мемоизации уже есть. Вторичное (вклад в «тормозит при открытии», но меньше рендера): `useCommentsQuery` грузит страницы по 100 **последовательно** (сервер: `@Max(100)` — [pagination-options.ts#L16](apps/server/src/database/pagination/pagination-options.ts#L16)) и не отдаёт ничего до полной загрузки (`isLoading: query.isLoading || query.hasNextPage` — [comment-query.ts#L54](apps/client/src/features/comment/queries/comment-query.ts#L54)). На 356 комментариев это 4+ последовательных round-trip до первого кадра. Транзакция `setCommentResolved` в основном редакторе (полный обход дока — [comment.ts#L137-L164](packages/editor-ext/src/lib/comment/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`, других узлов в комментариях быть не может. - **Mention**: извлечь из [mention-view.tsx](apps/client/src/features/editor/components/mention/mention-view.tsx) презентационную часть в общий компонент `MentionContent({ attrs })` (user-mention — span, page-mention — react-router `Link` c `usePageQuery`, как сейчас), использовать его и в TipTap NodeView, и в статическом рендерере. Запросы react-query дедуплицируются, меншены в комментариях редки — паритет поведения без массовых запросов. - **Fallback — предохранитель**: встретился неизвестный тип узла или контент не парсится как ProseMirror JSON → рендерим этот конкретный комментарий по-старому через read-only `CommentEditor` (плюс `console.warn` в dev). Прецедент «не-TipTap» обработки контента уже есть: [comment-content-to-text.ts](apps/client/src/features/comment/utils/comment-content-to-text.ts) (hover-preview). Учесть legacy-случай «content — не-JSON строка» → рендер как plain text. - Обернуть в тот же контейнер `classes.commentEditor`/ProseMirror-классы ([comment.module.css](apps/client/src/features/comment/components/comment.module.css)), чтобы CSS совпал пиксель-в-пиксель. - Заменить ветку `!isEditing` в [comment-list-item.tsx#L306-L307](apps/client/src/features/comment/components/comment-list-item.tsx#L306-L307) на `CommentContentView`; ветка редактирования остаётся на TipTap. Локальный `useState(content)` + `useEffect` синхронизации ([L72-L74](apps/client/src/features/comment/components/comment-list-item.tsx#L72-L74)) для отображения больше не нужны — статический рендер просто берёт `comment.content` из пропсов (websocket-обновления начинают работать «бесплатно», без `setContent` в чужой инстанс). ## 2. `keepMounted={false}` для вкладок `<Tabs keepMounted={false}>` в [comment-list-with-tabs.tsx#L203](apps/client/src/features/comment/components/comment-list-with-tabs.tsx#L203) — неактивная вкладка не монтируется вовсе. Открытие панели монтирует 30 элементов вместо 356. Побочный эффект: позиция общего скролла при переключении вкладок сбрасывается — приемлемо (списки разные). ## 3. Ленивый редактор ответа В `CommentEditorWithActions` ([comment-list-with-tabs.tsx#L360-L388](apps/client/src/features/comment/components/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` из deps `renderComments`: состояние отправки ответа перенести внутрь `CommentEditorWithActions` (локальный state / `mutation.isPending`). - Стабилизировать колбэки: зависеть от `createCommentMutation.mutateAsync` (стабильная ссылка), а не от объекта мутации. Итог: resolve/apply → перерендер одного элемента + дешёвое перестроение массивов вкладок; перемещение треда Open→Resolved остаётся анимационно мгновенным. # Вторичное (опционально, в этом же PR или follow-up) - **Прогрессивный первый кадр**: убрать `|| query.hasNextPage` из `isLoading` в [comment-query.ts#L54](apps/client/src/features/comment/queries/comment-query.ts#L54) — показывать первую сотню сразу, дозагружая остальные в фоне (счётчики на вкладках растут по мере загрузки — приемлемо). - **Виртуализация** обоих списков через `@tanstack/react-virtual` (уже используется в [doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) — вне скоупа v1: после пп. 1–4 статический список из сотен элементов дёшев; вернуться, если появятся страницы с тысячами комментариев. Учесть, что виртуализация ломает `scrollIntoView`-якоря (`data-comment-id`). # Крайние случаи - **Меншены/ссылки в комментариях** — паритет: клик по page-mention навигирует, user-mention стилизован; ссылки открываются как раньше. - **Legacy/битый контент** — не-JSON строка → plain text; неизвестный узел → fallback на read-only TipTap (комментарий-одиночка не тащит за собой деградацию всей панели). - **Websocket-обновление чужого комментария** — статический рендер обновляется из пропсов; ветка `useEffect setContent` больше не нужна. - **Редактирование** — ветка `isEditing` монтирует TipTap по требованию, как сейчас; после сохранения возврат к статическому рендеру. - **Черновик ответа** — лениво смонтированный редактор не размонтируется при перерендерах треда. - **Корректность мемоизации** — компаратор сравнивает `comment` по ссылке и дочерний массив поэлементно; оптимистичный resolve и server-authoritative onSuccess оба меняют ссылку только затронутого комментария. - **Пустые вкладки, переключение вкладок** — заглушки «No open/resolved comments» работают как раньше; сброс скролла при переключении — осознанный трейд-офф `keepMounted={false}`. # Тесты - **Render-тесты `CommentContentView`**: параграфы + марки (bold/italic/strike/code/link), `hardBreak`, mention (user/page), legacy plain-string, неизвестный узел → fallback-ветка. - **Адаптация [comment-list-item.test.tsx](apps/client/src/features/comment/components/comment-list-item.test.tsx)**: read-only ветка теперь статическая; suggestion-блок и Apply не затронуты. - **Поведенческий смок**: resolve перемещает тред между вкладками, reply-заглушка по клику превращается в редактор и сохраняет ввод. - **Ручной перф-замер** на реальной странице 350+ комментариев (Chrome DevTools Performance): время открытия панели до и после, время кадра на resolve/apply. # Вне скоупа v1 - Виртуализация списков (см. «Вторичное»). - Поднятие серверного `@Max(100)` пагинации. - Любые изменения сервера/MCP/схемы данных. - hover-preview и inline-диалог комментария (там уже нет проблемы: plain-text и один editable-инстанс). # План работ 1. `CommentContentView` + извлечение `MentionContent` из `MentionView` + fallback на read-only `CommentEditor`. 2. Замена read-only ветки в `comment-list-item.tsx` на статический рендер, чистка `setContent`-синхронизации. 3. `keepMounted={false}` на `Tabs`. 4. Ленивая заглушка reply-редактора в `CommentEditorWithActions`. 5. Мемоизация: `React.memo(CommentListItem)`, `childrenByParent`-Map, локализация `isLoading`, стабильные колбэки. 6. (Опция) прогрессивный первый кадр в `useCommentsQuery`. 7. Тесты + ручной перф-замер до/после.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#340