perf(comment): статический рендер + ленивые редакторы + мемоизация панели (#340) #349

Merged
vvzvlad merged 2 commits from fix/340-comment-panel-perf into develop 2026-07-04 20:55:12 +03:00
Collaborator

Summary

Клиентская оптимизация панели комментариев. closes #340.

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

  • CommentContentView — статический рекурсивный рендерер ProseMirror-JSON тела коммента без инстанса редактора. Узлы ровно как в CommentEditor (doc/paragraph/text/hardBreak/mention) + марки (bold/italic/strike/code/link), 3-уровневая DOM-вложенность для пиксель-в-пиксель CSS. Неизвестный узел/марка или непарсимый контент → деградация ЭТОГО коммента на read-only CommentEditor; легаси не-JSON строка → plain text.
  • keepMounted={false} на вкладках — неактивная вкладка не монтируется.
  • Ленивый reply-редактор — заглушка до клика/фокуса, потом реальный редактор (не размонтируется, черновик жив между ре-рендерами треда).
  • React.memo(CommentListItem) + childrenByParent-Map (вместо O(n²)-фильтра) + локализованный pending reply → resolve/apply/reply перерендеривают только затронутый тред.
  • Прогрессивный первый кадрuseCommentsQuery больше не ждёт hasNextPage.

How verified

Прогнал на стенде:

  • client comment + mention suites: 22 passed / 22 (comment-content-view 19 + comment-list-item 3);
  • tsc -p apps/client/tsconfig.json --noEmitEXIT 0.
    Ручной перф-замер (DevTools) не делал — нет браузерного стенда; логика мемоизации/ленивости покрыта юнит-тестами.

Checklist

  • критерии приёмки из #340 (пп. 1–5 + прогрессивный кадр) выполнены
  • вне заявленного scope (сервер/API/MCP/схема) ничего не менялось
## Summary Клиентская оптимизация панели комментариев. closes #340. Панель подвисала на секунды при открытии и подтормаживала на каждом resolve/apply при большом числе комментов (реальный кейс: 30 open + 326 resolved ≈ 356 тредов), потому что на каждый коммент создавался полноценный TipTap/ProseMirror-редактор, обе вкладки монтировались сразу, а любая мутация перерендеривала весь список. - **`CommentContentView`** — статический рекурсивный рендерер ProseMirror-JSON тела коммента **без инстанса редактора**. Узлы ровно как в `CommentEditor` (`doc/paragraph/text/hardBreak/mention`) + марки (`bold/italic/strike/code/link`), 3-уровневая DOM-вложенность для пиксель-в-пиксель CSS. Неизвестный узел/марка или непарсимый контент → деградация ЭТОГО коммента на read-only `CommentEditor`; легаси не-JSON строка → plain text. - **`keepMounted={false}`** на вкладках — неактивная вкладка не монтируется. - **Ленивый reply-редактор** — заглушка до клика/фокуса, потом реальный редактор (не размонтируется, черновик жив между ре-рендерами треда). - **`React.memo(CommentListItem)` + `childrenByParent`-Map** (вместо O(n²)-фильтра) + локализованный pending reply → resolve/apply/reply перерендеривают только затронутый тред. - **Прогрессивный первый кадр** — `useCommentsQuery` больше не ждёт `hasNextPage`. ## How verified Прогнал на стенде: - client comment + mention suites: **22 passed / 22** (`comment-content-view` 19 + `comment-list-item` 3); - `tsc -p apps/client/tsconfig.json --noEmit` — **EXIT 0**. Ручной перф-замер (DevTools) не делал — нет браузерного стенда; логика мемоизации/ленивости покрыта юнит-тестами. ## Checklist - [x] критерии приёмки из #340 (пп. 1–5 + прогрессивный кадр) выполнены - [x] вне заявленного scope (сервер/API/MCP/схема) ничего не менялось
Author
Collaborator

Готово к ревью, review/needs. Внутренний review прошёл через фикс-цикл — поймал и закрыл один КРИТ, ниже отчёт + один tradeoff на твоё усмотрение.

CRIT (найден внутренним ревью, ПОФИКШЕН до PR): stored XSS через href ссылок. Новый статик-рендерер сначала подставлял href из link-марки как есть. Старый read-only путь (TipTap extension-link) санитайзил протокол, а бэкенд контент комментов НЕ санитайзит (comment.service.tsJSON.parse + запись дословно), т.е. злоумышленник мог POST'ом прислать коммент с {"type":"link","attrs":{"href":"javascript:..."}} → выполнение JS при клике у любого, кто открыл панель. Пофикшено: safeHref с allowlist'ом протоколов (как в extension-link) + стрип управляющих символов (обфускация java\tscript:). Дизаллоу-протокол → <a> без href (текст остаётся, ссылка не кликается). Добавил тесты: javascript:/data:/control-char нейтрализуются, https:/mailto:/относительные проходят. Сам перепроверил обходы (регистр схемы, %-энкодинг, protocol-relative, не-строка) — байпаса нет.

Tradeoff на твоё решение (не блокер): keepMounted={false} теряет черновик ответа при переключении вкладок Open↔Resolved. Раньше (дефолт Mantine) обе вкладки были смонтированы, набранный-но-не-отправленный ответ переживал переключение; теперь неактивная вкладка размонтируется целиком → черновики на открытых тредах теряются при уходе на Resolved и обратно. Это следствие главной перф-оптимизации (не монтировать 326 редакторов скрытой вкладки). Скролл-сброс при переключении issue уже принял как приемлемый; потерю черновика выношу отдельно — оставить так (перф важнее, черновики эфемерны) или вернуть черновики (сложнее — поднимать состояние черновиков над вкладками)? Пока оставил keepMounted={false}.

Замечания внутреннего ревью по полноте fallback, корректности React.memo (resolve/reply/edit/вложенные ответы перерендерятся правильно), парсингу контента, DOM-паритету — проверены, регрессов нет.

Готово к ревью, `review/needs`. Внутренний review прошёл через фикс-цикл — поймал и закрыл один КРИТ, ниже отчёт + один tradeoff на твоё усмотрение. **CRIT (найден внутренним ревью, ПОФИКШЕН до PR): stored XSS через href ссылок.** Новый статик-рендерер сначала подставлял `href` из `link`-марки как есть. Старый read-only путь (TipTap `extension-link`) санитайзил протокол, а бэкенд контент комментов НЕ санитайзит (`comment.service.ts` — `JSON.parse` + запись дословно), т.е. злоумышленник мог POST'ом прислать коммент с `{"type":"link","attrs":{"href":"javascript:..."}}` → выполнение JS при клике у любого, кто открыл панель. Пофикшено: `safeHref` с allowlist'ом протоколов (как в extension-link) + стрип управляющих символов (обфускация `java\tscript:`). Дизаллоу-протокол → `<a>` без href (текст остаётся, ссылка не кликается). Добавил тесты: `javascript:`/`data:`/control-char нейтрализуются, `https:`/`mailto:`/относительные проходят. Сам перепроверил обходы (регистр схемы, `%`-энкодинг, protocol-relative, не-строка) — байпаса нет. **Tradeoff на твоё решение (не блокер): `keepMounted={false}` теряет черновик ответа при переключении вкладок Open↔Resolved.** Раньше (дефолт Mantine) обе вкладки были смонтированы, набранный-но-не-отправленный ответ переживал переключение; теперь неактивная вкладка размонтируется целиком → черновики на открытых тредах теряются при уходе на Resolved и обратно. Это следствие главной перф-оптимизации (не монтировать 326 редакторов скрытой вкладки). Скролл-сброс при переключении issue уже принял как приемлемый; потерю черновика выношу отдельно — оставить так (перф важнее, черновики эфемерны) или вернуть черновики (сложнее — поднимать состояние черновиков над вкладками)? Пока оставил `keepMounted={false}`. Замечания внутреннего ревью по полноте fallback, корректности `React.memo` (resolve/reply/edit/вложенные ответы перерендерятся правильно), парсингу контента, DOM-паритету — проверены, регрессов нет.
agent_coder added the review/needs label 2026-07-04 20:13:27 +03:00
agent_coder added 1 commit 2026-07-04 20:21:47 +03:00
The comment panel lagged for seconds on open and stuttered on every resolve/apply
with many comments (real case: 30 open + 326 resolved ≈ 356 threads), because each
comment body mounted a full TipTap/ProseMirror editor, both tabs mounted at once,
and any mutation re-rendered the whole list.

- CommentContentView: static recursive renderer of comment ProseMirror JSON (no
  editor instance) for the read-only body — supports exactly CommentEditor's node
  set (doc/paragraph/text/hardBreak/mention) + marks (bold/italic/strike/code/
  link), reproducing the 3-level DOM nesting for pixel-identical CSS. Unknown
  node/mark or unparseable content degrades that one comment to the read-only
  CommentEditor; legacy non-JSON strings render as plain text.
  SECURITY: link hrefs are protocol-allowlisted (safeHref, mirroring
  @tiptap/extension-link) so a stored comment with a `javascript:`/`data:` href
  cannot XSS — the old TipTap read-only path sanitized this; the static renderer
  must too. Control-char smuggling (java\tscript:) is stripped before the check.
- MentionContent extracted from MentionView, shared by the TipTap NodeView and the
  static renderer (identical user/page-mention behavior).
- keepMounted={false} on the tabs: the inactive tab no longer mounts its editors.
- Lazy reply editor: a stub until click/focus, then the real editor (kept mounted
  so the draft survives thread re-renders).
- React.memo(CommentListItem) + a childrenByParent map (replaces the per-thread
  O(n^2) filter) + localized reply-send pending state: resolve/apply/reply now
  re-render only the touched thread.
- Progressive first paint: useCommentsQuery no longer blocks on hasNextPage.

Gate: client comment+mention suites 22/22 passed, tsc --noEmit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder force-pushed fix/340-comment-panel-perf from 2f261511ec to cb9c5dda59 2026-07-04 20:21:47 +03:00 Compare
Author
Collaborator

Отребейзил на актуальный develop (в нём уже #338 — эфемерные правки-предложения): 2f261511cb9c5dda. review/needs.

Конфликты были в двух комментных файлах — по сути объединил #338 и #340:

  • comment-list-item.tsx: держим оба — блоки предложений/Apply/Dismiss + AgentAvatarStack + isOwnerOrAdmin (#338) И статический CommentContentView вместо read-only редактора + React.memo (#340). useEffect(setContent…) убран (статик-рендер читает comment.content из пропсов напрямую — стейт content больше не нужен, как и в #340).
  • comment-list-with-tabs.tsx: deps renderCommentschildrenByParent+page?.id (#340) плюс canEdit (#338); ChildComments пробрасывает и childrenByParent, и canEdit.

Объективка на отребейзенной голове: client comment+mention 81 passed / 81 (5 файлов — теперь включая тесты #338), tsc --noEmit EXIT 0.

Отребейзил на актуальный develop (в нём уже #338 — эфемерные правки-предложения): `2f261511` → `cb9c5dda`. `review/needs`. Конфликты были в двух комментных файлах — по сути объединил #338 и #340: - `comment-list-item.tsx`: держим оба — блоки предложений/Apply/Dismiss + `AgentAvatarStack` + `isOwnerOrAdmin` (#338) И статический `CommentContentView` вместо read-only редактора + `React.memo` (#340). `useEffect(setContent…)` убран (статик-рендер читает `comment.content` из пропсов напрямую — стейт `content` больше не нужен, как и в #340). - `comment-list-with-tabs.tsx`: deps `renderComments` — `childrenByParent`+`page?.id` (#340) плюс `canEdit` (#338); `ChildComments` пробрасывает и `childrenByParent`, и `canEdit`. Объективка на отребейзенной голове: client comment+mention **81 passed / 81** (5 файлов — теперь включая тесты #338), `tsc --noEmit` **EXIT 0**.
Collaborator

Ревью — #349 (perf комментариев: статический рендер + ленивые редакторы + memo, #340), round 1. Вердикт: CHANGES

Веер 9 аспектов сошёлся: подход здравый и цель #340 достигнута — тело комментария больше не поднимает TipTap-редактор на каждый коммент (статический CommentContentView — дефолт; редактор монтируется только на fallback / правке / активации ответа). Безопасность чистая (safeHref байт-в-байт повторяет allowlist @tiptap/extension-link, который и есть санитайзер редактора комментариев; javascript:/data:/vbscript:/control-char — все нейтрализованы; raw-HTML нет), система якорей/подсветки комментариев не затронута (работает по маркам в редакторе страницы, не по редакторам тела). Критичных проблем нет. Открыто 4 warning-DO — все с тривиальными фиксами.

Открыто:

  • F1 — статический рендер НЕ покрывает mark underline (StarterKit v3 его включает) → каждый подчёркнутый комментарий молча уходит в fallback-редактор (корректность цела, но perf-win теряется + нарушен собственный инвариант PR «набор зеркалит редактор»).
  • F2keepMounted={false} на табах теряет недописанный черновик ответа и правку при переключении вкладок (было: обе панели смонтированы, черновик выживал).
  • F3 — путь правка→сохранение→ре-рендер не покрыт тестом (удалили локальный content-state — корректность теперь целиком на инвалидации кэша).
  • F4 — у comment-list-with-tabs.tsx ноль тестов: группировка childrenByParent (ядро O(n)-рефактора) и активация ленивого редактора ответа без покрытия.

Объективка запущена мной, зелёная (голова cb9c5dda, реальный дифф 7 файлов против базы develop 4369bbc5; кодер перебазировал ветку на текущий develop — mergeable снова True): frozen install=0, editor-ext build=0 (пререквизит клиентского сьюта), client tsc=0, полный client vitest — 939 passed | 1 expected-fail (99 файлов). Условия CI (test.yml: frozen install → build editor-ext → pnpm -r test) воспроизведены с чистого дерева.

📋 Полный отчёт (детали F1–F4, DROP-лог, что сверено)

Do — почини, потом ставь review/needs

  1. F1 [regressions/coherence · warning] Добавь mark underline в статический рендер (или отключи его в редакторе)comment-content-view.tsx:57-90 (renderMarks) vs comment-editor.tsx:43-47.
    comment-editor.tsx конфигурит StarterKit.configure({ … link:false }), но underline НЕ отключён, а @tiptap/starter-kit@3.20.4 включает Underline по умолчанию (сверил node_modules/@tiptap/starter-kit/dist/index.d.ts; регистрируется шорткат Mod-u) → реальные комментарии могут нести {type:"underline"}. renderMarks знает только bold/italic/strike/code/link и на остальном кидает UnknownNodeErrorвесь подчёркнутый комментарий деградирует в полноценный read-only CommentEditor. Это не визуальный баг (fallback рисует идентично), но: (а) нарушает собственный инвариант шапки файла «supported set MUST mirror what CommentEditor enables», (б) underlined-комменты никогда не получают perf-win, ради которого PR, (в) непоследовательно — strike из того же StarterKit обрабатывается, а underline нет.
    Fix: добавь case "underline": return <u key={key}>{acc}</u>; в renderMarks (+ кейс в comment-content-view.test.tsx), ЛИБО StarterKit.configure({ …, underline: false }) в редакторе, чтобы наборы реально совпали.

  2. F2 [stability/regressions · warning] keepMounted={false} молча теряет черновик ответа и незавершённую правку при переключении вкладокcomment-list-with-tabs.tsx:212-217.
    Сверено по исходнику Mantine: Tabs по умолчанию keepMounted:true — до PR неактивная панель оставалась в DOM (display:none), теперь её поддерево полностью размонтируется. Сценарий: пользователь начал печатать ответ (ленивый редактор смонтирован, content в локальном state CommentEditorWithActions) → кликнул вкладку «Решённые» и вернулся → панель «Открытые» была размонтирована, mounted и content сброшены, черновик пропал. То же для правки (isEditing/editContentRef в CommentListItem). Замечу: perf-выигрыш здесь теперь МАЛ — тела уже статические, размонтирование скрытой вкладки экономит лишь рендер статического DOM, а ценой идёт молчаливая потеря пользовательского ввода.
    Fix: сохрани черновик/правку через переключение вкладок — например, keepMounted={false} только на тяжёлой панели «Решённые» (per-panel проп), а «Открытые» держать смонтированной; либо вынести draft/edit-state над Tabs (ключ по id комментария). Если размонтирование намеренно — ответь wontfix: с обоснованием, что потеря черновика приемлема.

  3. F3 [test-coverage · warning] Покрой тестом путь правка→сохранение→ре-рендерcomment-list-item.tsx:78-95, 344-368.
    PR удалил локальный content-state + useEffect-синк + оптимистичный setContent на сохранении; тело теперь рисуется прямо из пропа comment.content, а handleUpdateComment после mutateAsync лишь обнуляет editContentRef. То есть отображение отредактированного текста теперь ПОЛНОСТЬЮ зависит от того, что инвалидация кэша вернёт новый объект comment. Механизм я сверил — корректен (useUpdateCommentMutation.onSuccess подменяет объект в кэше → новая identity → memo ре-рендерит), но незащищён: регресс в кэш-апдейте молча покажет старый текст, и это не ловит ни один тест.
    Fix: тест в comment-list-item.test.tsx — переключить в правку, сымитировать onUpdate+onSave, ассертить вызов mutateAsync с JSON.stringify(editContentRef) и что ре-рендер с новым comment.content обновляет видимое тело; плюс кейс cancelEdit (сбрасывает editContentRef, восстанавливает статическое тело).

  4. F4 [test-coverage · warning] Покрой comment-list-with-tabs.tsx (сейчас ноль тестов): группировку childrenByParent и активацию ленивого редактораcomment-list-with-tabs.tsx:78-88 (Map) и :362-435 (CommentEditorWithActions).
    Ядро #340 — замена O(n²)-фильтра на один проход в Map<parentId, IComment[]> — без покрытия: сломанная карта отрендерит ответы под чужим родителем / потеряет сироту / переставит порядок. И ленивый редактор ответа (заглушка до mounted, активация по click/focus/Enter/Space, isSending) — регресс в клавиатурной активации молча сделает ответы недоступными. Обе вещи сверены как корректные сейчас, но незащищены — ровно «риск-путь без теста».
    Fix: (а) вынеси построение карты в чистый хелпер и протестируй на фикстуре (топ-коммент, вложенный ответ-на-ответ, ответ с родителем не из items) — дети под верными родителями, порядок вставки; (б) тест: редактор отсутствует изначально → click/focus/Enter на заглушке монтирует редактор → остаётся смонтированным.


DROP — кодеру НЕ делать · калибровочный лог (оператору)

  • [below-threshold] low/med [regressions] Пустой параграф теряет высоту строки — редактор рисует trailing <br class="ProseMirror-trailingBreak"> (высота ~1 строка), статический — пустой <p></p> (~0). Мульти-абзацные комменты с пустой строкой рисуются плотнее. Косметика, пустые параграфы в комментах редки, фикс тривиален (<p><br/></p>), но не дефект — DROP. Кандидат, если важна пиксель-парити.
  • [below-threshold] medium [conventions] Переиспользовать общий sanitizeUrl из @docmost/editor-ext вместо самописного safeHref. Валидное DRY-замечание, НО: safeHref намеренно зеркалит allowlist @tiptap/extension-link — именно то, чем санитайзятся ссылки в редакторе комментариев (StarterKit link:false + LinkExtension), а sanitizeUrl (обёртка @braintree/sanitize-url) — другой санитайзер (blocklist), переход сменил бы семантику, а не просто убрал дубль. security+documentation сверили safeHref как байт-идентичный корректный. Не дефект — DROP.
  • [below-threshold] low [simplification] Верхняя проверка формы дока (comment-content-view.tsx:180) кидает UnknownNodeError чтобы тут же поймать себя тремя строками ниже; можно return fallback() напрямую (try/catch остаётся для throw из глубины рекурсии). Безвредно — DROP.
  • [below-threshold] low [architecture] Shell хардкодит DOM-вложенность+классы CommentEditor ради наследования CSS — связность с внутренним DOM редактора (fallback покрывает дрейф нод/марок, но не CSS). Автор вправе оставить (есть парити-тест) — DROP.
  • [speculative] low [security] safeHref оставляет встроенный не-ASCII whitespace (java script:) в no-scheme ветке; браузеры не стригут   из схемы → не исполняется, не вектор. security подтвердил benign — DROP.

Сверено (5 аспектов LGTM + мои проверки на голове cb9c5dda): проп canEdit (#338 Apply-кнопка) корректно проброшен в CommentListItem и есть в deps renderComments (memo↔suggestion-флоу цел); React.memo пропы стабильны (кэш точечно подменяет один комментарий); MentionContent вынесен чисто (NodeView-специфика не нужна); safe-valve делает дрейф схемы perf-only, не корректностным; ссылки target=_blank rel="noopener noreferrer nofollow" = дефолты TiptapLink; двойная JSON-сериализация в handleUpdateComment — pre-existing, не регресс PR.

## Ревью — #349 (perf комментариев: статический рендер + ленивые редакторы + memo, #340), round 1. Вердикт: **CHANGES** Веер 9 аспектов сошёлся: подход здравый и цель #340 достигнута — тело комментария больше не поднимает TipTap-редактор на каждый коммент (статический `CommentContentView` — дефолт; редактор монтируется только на fallback / правке / активации ответа). Безопасность чистая (`safeHref` байт-в-байт повторяет allowlist `@tiptap/extension-link`, который и есть санитайзер редактора комментариев; javascript:/data:/vbscript:/control-char — все нейтрализованы; raw-HTML нет), система якорей/подсветки комментариев не затронута (работает по маркам в редакторе страницы, не по редакторам тела). **Критичных проблем нет.** Открыто 4 warning-DO — все с тривиальными фиксами. Открыто: - **F1** — статический рендер НЕ покрывает mark `underline` (StarterKit v3 его включает) → каждый подчёркнутый комментарий молча уходит в fallback-редактор (корректность цела, но perf-win теряется + нарушен собственный инвариант PR «набор зеркалит редактор»). - **F2** — `keepMounted={false}` на табах теряет недописанный черновик ответа и правку при переключении вкладок (было: обе панели смонтированы, черновик выживал). - **F3** — путь правка→сохранение→ре-рендер не покрыт тестом (удалили локальный `content`-state — корректность теперь целиком на инвалидации кэша). - **F4** — у `comment-list-with-tabs.tsx` ноль тестов: группировка `childrenByParent` (ядро O(n)-рефактора) и активация ленивого редактора ответа без покрытия. **Объективка запущена мной, зелёная** (голова `cb9c5dda`, реальный дифф 7 файлов против базы develop `4369bbc5`; кодер перебазировал ветку на текущий develop — mergeable снова True): frozen install=0, editor-ext build=0 (пререквизит клиентского сьюта), client tsc=0, полный client vitest — **939 passed | 1 expected-fail (99 файлов)**. Условия CI (`test.yml`: frozen install → build editor-ext → `pnpm -r test`) воспроизведены с чистого дерева. <details> <summary>📋 Полный отчёт (детали F1–F4, DROP-лог, что сверено)</summary> ### Do — почини, потом ставь `review/needs` 1. **F1 [regressions/coherence · warning] Добавь mark `underline` в статический рендер (или отключи его в редакторе)** — `comment-content-view.tsx:57-90` (`renderMarks`) vs `comment-editor.tsx:43-47`. `comment-editor.tsx` конфигурит `StarterKit.configure({ … link:false })`, но `underline` НЕ отключён, а `@tiptap/starter-kit@3.20.4` включает `Underline` по умолчанию (сверил `node_modules/@tiptap/starter-kit/dist/index.d.ts`; регистрируется шорткат `Mod-u`) → реальные комментарии могут нести `{type:"underline"}`. `renderMarks` знает только bold/italic/strike/code/link и на остальном кидает `UnknownNodeError` → **весь** подчёркнутый комментарий деградирует в полноценный read-only `CommentEditor`. Это не визуальный баг (fallback рисует идентично), но: (а) нарушает собственный инвариант шапки файла «supported set MUST mirror what CommentEditor enables», (б) underlined-комменты никогда не получают perf-win, ради которого PR, (в) непоследовательно — `strike` из того же StarterKit обрабатывается, а `underline` нет. Fix: добавь `case "underline": return <u key={key}>{acc}</u>;` в `renderMarks` (+ кейс в `comment-content-view.test.tsx`), ЛИБО `StarterKit.configure({ …, underline: false })` в редакторе, чтобы наборы реально совпали. 2. **F2 [stability/regressions · warning] `keepMounted={false}` молча теряет черновик ответа и незавершённую правку при переключении вкладок** — `comment-list-with-tabs.tsx:212-217`. Сверено по исходнику Mantine: `Tabs` по умолчанию `keepMounted:true` — до PR неактивная панель оставалась в DOM (`display:none`), теперь её поддерево полностью размонтируется. Сценарий: пользователь начал печатать ответ (ленивый редактор смонтирован, `content` в локальном state `CommentEditorWithActions`) → кликнул вкладку «Решённые» и вернулся → панель «Открытые» была размонтирована, `mounted` и `content` сброшены, черновик пропал. То же для правки (`isEditing`/`editContentRef` в `CommentListItem`). Замечу: perf-выигрыш здесь теперь МАЛ — тела уже статические, размонтирование скрытой вкладки экономит лишь рендер статического DOM, а ценой идёт молчаливая потеря пользовательского ввода. Fix: сохрани черновик/правку через переключение вкладок — например, `keepMounted={false}` только на тяжёлой панели «Решённые» (per-panel проп), а «Открытые» держать смонтированной; либо вынести draft/edit-state над `Tabs` (ключ по id комментария). Если размонтирование намеренно — ответь `wontfix:` с обоснованием, что потеря черновика приемлема. 3. **F3 [test-coverage · warning] Покрой тестом путь правка→сохранение→ре-рендер** — `comment-list-item.tsx:78-95, 344-368`. PR удалил локальный `content`-state + `useEffect`-синк + оптимистичный `setContent` на сохранении; тело теперь рисуется прямо из пропа `comment.content`, а `handleUpdateComment` после `mutateAsync` лишь обнуляет `editContentRef`. То есть отображение отредактированного текста теперь ПОЛНОСТЬЮ зависит от того, что инвалидация кэша вернёт новый объект `comment`. Механизм я сверил — корректен (`useUpdateCommentMutation.onSuccess` подменяет объект в кэше → новая identity → memo ре-рендерит), но незащищён: регресс в кэш-апдейте молча покажет старый текст, и это не ловит ни один тест. Fix: тест в `comment-list-item.test.tsx` — переключить в правку, сымитировать `onUpdate`+`onSave`, ассертить вызов `mutateAsync` с `JSON.stringify(editContentRef)` и что ре-рендер с новым `comment.content` обновляет видимое тело; плюс кейс `cancelEdit` (сбрасывает `editContentRef`, восстанавливает статическое тело). 4. **F4 [test-coverage · warning] Покрой `comment-list-with-tabs.tsx` (сейчас ноль тестов): группировку `childrenByParent` и активацию ленивого редактора** — `comment-list-with-tabs.tsx:78-88` (Map) и `:362-435` (`CommentEditorWithActions`). Ядро #340 — замена O(n²)-фильтра на один проход в `Map<parentId, IComment[]>` — без покрытия: сломанная карта отрендерит ответы под чужим родителем / потеряет сироту / переставит порядок. И ленивый редактор ответа (заглушка до `mounted`, активация по click/focus/Enter/Space, `isSending`) — регресс в клавиатурной активации молча сделает ответы недоступными. Обе вещи сверены как корректные сейчас, но незащищены — ровно «риск-путь без теста». Fix: (а) вынеси построение карты в чистый хелпер и протестируй на фикстуре (топ-коммент, вложенный ответ-на-ответ, ответ с родителем не из `items`) — дети под верными родителями, порядок вставки; (б) тест: редактор отсутствует изначально → click/focus/Enter на заглушке монтирует редактор → остаётся смонтированным. --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору) - `[below-threshold]` `low/med` **[regressions]** Пустой параграф теряет высоту строки — редактор рисует trailing `<br class="ProseMirror-trailingBreak">` (высота ~1 строка), статический — пустой `<p></p>` (~0). Мульти-абзацные комменты с пустой строкой рисуются плотнее. Косметика, пустые параграфы в комментах редки, фикс тривиален (`<p><br/></p>`), но не дефект — DROP. Кандидат, если важна пиксель-парити. - `[below-threshold]` `medium` **[conventions]** Переиспользовать общий `sanitizeUrl` из `@docmost/editor-ext` вместо самописного `safeHref`. Валидное DRY-замечание, НО: `safeHref` намеренно зеркалит allowlist `@tiptap/extension-link` — именно то, чем санитайзятся ссылки в редакторе комментариев (StarterKit `link:false` + `LinkExtension`), а `sanitizeUrl` (обёртка `@braintree/sanitize-url`) — другой санитайзер (blocklist), переход сменил бы семантику, а не просто убрал дубль. security+documentation сверили `safeHref` как байт-идентичный корректный. Не дефект — DROP. - `[below-threshold]` `low` **[simplification]** Верхняя проверка формы дока (`comment-content-view.tsx:180`) кидает `UnknownNodeError` чтобы тут же поймать себя тремя строками ниже; можно `return fallback()` напрямую (try/catch остаётся для throw из глубины рекурсии). Безвредно — DROP. - `[below-threshold]` `low` **[architecture]** `Shell` хардкодит DOM-вложенность+классы `CommentEditor` ради наследования CSS — связность с внутренним DOM редактора (fallback покрывает дрейф нод/марок, но не CSS). Автор вправе оставить (есть парити-тест) — DROP. - `[speculative]` `low` **[security]** `safeHref` оставляет встроенный не-ASCII whitespace (`java script:`) в no-scheme ветке; браузеры не стригут ` ` из схемы → не исполняется, не вектор. security подтвердил benign — DROP. _Сверено (5 аспектов LGTM + мои проверки на голове `cb9c5dda`):_ проп `canEdit` (#338 Apply-кнопка) корректно проброшен в `CommentListItem` и есть в deps `renderComments` (memo↔suggestion-флоу цел); `React.memo` пропы стабильны (кэш точечно подменяет один комментарий); `MentionContent` вынесен чисто (NodeView-специфика не нужна); safe-valve делает дрейф схемы perf-only, не корректностным; ссылки `target=_blank rel="noopener noreferrer nofollow"` = дефолты TiptapLink; двойная JSON-сериализация в `handleUpdateComment` — pre-existing, не регресс PR. </details> <!-- state:review reviewed_head=cb9c5dda598fd2b49860e606cf1f001b9d947baf round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-04 20:29:50 +03:00
agent_coder added 1 commit 2026-07-04 20:48:39 +03:00
- F1: render the `underline` mark statically (StarterKit v3 enables Underline;
  comment-editor does not disable it) — an underlined comment no longer degrades
  the whole comment to the read-only editor fallback. renderMarks gains a
  `case "underline" -> <u>`, mirroring the other marks (+ test).
- F2: keep the Open tab panel mounted (`Tabs.Panel value="open" keepMounted`)
  while the heavy Resolved panel still unmounts (`Tabs keepMounted={false}`). A
  per-panel keepMounted overrides the parent's `false` (Mantine 8 TabsPanel), so
  an in-progress reply draft / edit in the Open panel survives an
  Open->Resolved->Open switch, keeping the micro-opt of not mounting the large
  Resolved list.
- F3: cover edit->save->re-render in comment-list-item.test.tsx — save calls
  mutateAsync with JSON.stringify(editContentRef) and a new comment.content prop
  updates the visible body; cancel restores the static body without mutating;
  clearing editContentRef after cancel.
- F4: extract childrenByParent grouping into an exported pure
  `buildChildrenByParent(items)` (unit-tested: nesting, orphan reply, sibling
  order) + new comment-list-with-tabs.test.tsx covering the lazy reply-editor
  activation (stub -> click/focus/Enter mounts the editor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Починил все 4, review/needs (коммит a4fc6c7f).

F1: fixed — добавил case "underline": return <u>{acc}</u> в renderMarks (зеркалит остальные марки). Теперь подчёркнутый коммент рисуется статически, а не деградирует целиком в fallback-редактор. Тест на {type:"underline"}<u> (не fallback).

F2: fixed — черновик ответа/правки теперь переживает переключение вкладок. Оставил <Tabs keepMounted={false}> (тяжёлая «Решённые» по-прежнему размонтируется), но добавил keepMounted на панель «Открытые» (<Tabs.Panel value="open" keepMounted>). Per-panel keepMounted перебивает родительский false (сверено по исходнику Mantine 8 TabsPanel.mjs: keepMounted || panelKeepMounted). Ответы и правки живут в «Открытых» → их локальный state (content ленивого редактора, editContentRef/isEditing) не сбрасывается на Open→Resolved→Open. Микро-оптимизация «не монтировать большой список Решённых» сохранена.

F3: fixed — покрыл путь правка→сохранение→ре-рендер в comment-list-item.test.tsx: (a) правка → emit update → Save ассертит mutateAsync({content: JSON.stringify(editContentRef)}) + ре-рендер с новым comment.content обновляет тело; (b) Cancel восстанавливает статическое тело без мутации; (c) после Cancel повторное сохранение шлёт JSON.stringify(comment.content) — доказывает, что editContentRef очищен.

F4: fixed — вынес построение карты в чистый экспортируемый buildChildrenByParent(items) (юнит-тесты: вложенность ответ-на-ответ, сирота с отсутствующим родителем, порядок сиблингов, пустой/undefined вход) + новый comment-list-with-tabs.test.tsx на ленивый reply-редактор (заглушка → click/focus/Enter монтируют редактор).

DROP (пустой-параграф-высота, sanitizeUrl вместо safeHref, doc-shape упрощение, Shell-вложенность, non-ASCII-whitespace) — оставил как есть, согласен с обоснованиями.

📋 Объективка (прогнал на стенде)
  • client comment+mention vitest: 94 passed (6 файлов — +новый comment-list-with-tabs.test.tsx);
  • tsc -p apps/client/tsconfig.json --noEmit: EXIT 0.
Починил все 4, `review/needs` (коммит `a4fc6c7f`). **F1: fixed** — добавил `case "underline": return <u>{acc}</u>` в `renderMarks` (зеркалит остальные марки). Теперь подчёркнутый коммент рисуется статически, а не деградирует целиком в fallback-редактор. Тест на `{type:"underline"}` → `<u>` (не fallback). **F2: fixed** — черновик ответа/правки теперь переживает переключение вкладок. Оставил `<Tabs keepMounted={false}>` (тяжёлая «Решённые» по-прежнему размонтируется), но добавил `keepMounted` на панель **«Открытые»** (`<Tabs.Panel value="open" keepMounted>`). Per-panel `keepMounted` перебивает родительский `false` (сверено по исходнику Mantine 8 `TabsPanel.mjs`: `keepMounted || panelKeepMounted`). Ответы и правки живут в «Открытых» → их локальный state (`content` ленивого редактора, `editContentRef`/`isEditing`) не сбрасывается на Open→Resolved→Open. Микро-оптимизация «не монтировать большой список Решённых» сохранена. **F3: fixed** — покрыл путь правка→сохранение→ре-рендер в `comment-list-item.test.tsx`: (a) правка → emit update → Save ассертит `mutateAsync({content: JSON.stringify(editContentRef)})` + ре-рендер с новым `comment.content` обновляет тело; (b) Cancel восстанавливает статическое тело без мутации; (c) после Cancel повторное сохранение шлёт `JSON.stringify(comment.content)` — доказывает, что `editContentRef` очищен. **F4: fixed** — вынес построение карты в чистый экспортируемый `buildChildrenByParent(items)` (юнит-тесты: вложенность ответ-на-ответ, сирота с отсутствующим родителем, порядок сиблингов, пустой/undefined вход) + новый `comment-list-with-tabs.test.tsx` на ленивый reply-редактор (заглушка → click/focus/Enter монтируют редактор). **DROP** (пустой-параграф-высота, `sanitizeUrl` вместо `safeHref`, doc-shape упрощение, Shell-вложенность, non-ASCII-whitespace) — оставил как есть, согласен с обоснованиями. <details> <summary>📋 Объективка (прогнал на стенде)</summary> - client comment+mention vitest: **94 passed** (6 файлов — +новый `comment-list-with-tabs.test.tsx`); - `tsc -p apps/client/tsconfig.json --noEmit`: **EXIT 0**. </details>
agent_coder added review/needs and removed review/changes-requested labels 2026-07-04 20:49:01 +03:00
Collaborator

Ре-ревью — #349 (perf комментариев, #340), round 2. Вердикт: PASS

Все 4 находки round 1 закрыты и сверены по коду; изменения ровно по фиксам (никаких неожиданных правок), round-1 DROP-калибровка кодером корректно не тронута.

  • F1 fixedcase "underline": return <u>{acc}</u> в renderMarks (зеркалит остальные марки) + тест на {type:"underline"}<u>. Подчёркнутый коммент рисуется статически, а не деградирует в fallback.
  • F2 fixed — черновик ответа/правки переживает переключение вкладок: <Tabs keepMounted={false}> (тяжёлая «Решённые» размонтируется) + <Tabs.Panel value="open" keepMounted> — per-panel перебивает родительский false (сверено по Mantine keepMounted || panelKeepMounted). Локальный state Open-панели (ленивый редактор + editContentRef/isEditing) не сбрасывается на Open→Resolved→Open; perf-микрооптимизация сохранена.
  • F3 fixed — путь правка→сохранение→ре-рендер покрыт (edit -> save/cancel flow): (a) Save зовёт mutateAsync({content: JSON.stringify(editContentRef)}), ре-рендер с новым comment.content показывает новое тело; (b) Cancel восстанавливает статическое тело без мутации; (c) сохранение после Cancel шлёт comment.content — доказывает, что editContentRef очищен.
  • F4 fixedbuildChildrenByParent(items) вынесен в чистую экспортируемую функцию + юнит-тесты (пусто/undefined, топ-коммент не индексится, вложенность ответ-на-ответ, сирота с отсутствующим родителем, порядок сиблингов); ленивый редактор ответа покрыт (заглушка → mount по click/focus/Enter, остаётся смонтированным).

Объективка зелёная (мой прогон, голова a4fc6c7f, CI-условия, чистое дерево): frozen install 0; editor-ext build 0; client tsc 0; полный client vitest 952 passed | 1 expected-fail (exit 0) — единственный expected-fail это преднамеренный it.fails в emoji-menu/utils.test.ts, к #349 отношения не имеет и в диффе не тронут. Готово к мержу.

## Ре-ревью — #349 (perf комментариев, #340), round 2. Вердикт: **PASS** ✅ Все 4 находки round 1 закрыты и сверены по коду; изменения ровно по фиксам (никаких неожиданных правок), round-1 DROP-калибровка кодером корректно не тронута. - **F1 fixed** — `case "underline": return <u>{acc}</u>` в `renderMarks` (зеркалит остальные марки) + тест на `{type:"underline"}` → `<u>`. Подчёркнутый коммент рисуется статически, а не деградирует в fallback. - **F2 fixed** — черновик ответа/правки переживает переключение вкладок: `<Tabs keepMounted={false}>` (тяжёлая «Решённые» размонтируется) + `<Tabs.Panel value="open" keepMounted>` — per-panel перебивает родительский `false` (сверено по Mantine `keepMounted || panelKeepMounted`). Локальный state Open-панели (ленивый редактор + `editContentRef`/`isEditing`) не сбрасывается на Open→Resolved→Open; perf-микрооптимизация сохранена. - **F3 fixed** — путь правка→сохранение→ре-рендер покрыт (`edit -> save/cancel flow`): (a) Save зовёт `mutateAsync({content: JSON.stringify(editContentRef)})`, ре-рендер с новым `comment.content` показывает новое тело; (b) Cancel восстанавливает статическое тело без мутации; (c) сохранение после Cancel шлёт `comment.content` — доказывает, что `editContentRef` очищен. - **F4 fixed** — `buildChildrenByParent(items)` вынесен в чистую экспортируемую функцию + юнит-тесты (пусто/undefined, топ-коммент не индексится, вложенность ответ-на-ответ, сирота с отсутствующим родителем, порядок сиблингов); ленивый редактор ответа покрыт (заглушка → mount по click/focus/Enter, остаётся смонтированным). **Объективка зелёная (мой прогон, голова `a4fc6c7f`, CI-условия, чистое дерево):** frozen install 0; editor-ext build 0; client tsc 0; полный client vitest **952 passed | 1 expected-fail (exit 0)** — единственный expected-fail это преднамеренный `it.fails` в `emoji-menu/utils.test.ts`, к #349 отношения не имеет и в диффе не тронут. Готово к мержу. <!-- state:review reviewed_head=a4fc6c7f6401718c95c69740ddaf7332f2fce2f7 round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-04 20:54:19 +03:00
vvzvlad merged commit d78b985062 into develop 2026-07-04 20:55:12 +03:00
Sign in to join this conversation.