perf(comment): статический рендер + ленивые редакторы + мемоизация панели (#340) #349
Reference in New Issue
Block a user
Delete Branch "fix/340-comment-panel-perf"
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?
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-onlyCommentEditor; легаси не-JSON строка → plain text.keepMounted={false}на вкладках — неактивная вкладка не монтируется.React.memo(CommentListItem)+childrenByParent-Map (вместо O(n²)-фильтра) + локализованный pending reply → resolve/apply/reply перерендеривают только затронутый тред.useCommentsQueryбольше не ждётhasNextPage.How verified
Прогнал на стенде:
comment-content-view19 +comment-list-item3);tsc -p apps/client/tsconfig.json --noEmit— EXIT 0.Ручной перф-замер (DevTools) не делал — нет браузерного стенда; логика мемоизации/ленивости покрыта юнит-тестами.
Checklist
Готово к ревью,
review/needs. Внутренний review прошёл через фикс-цикл — поймал и закрыл один КРИТ, ниже отчёт + один tradeoff на твоё усмотрение.CRIT (найден внутренним ревью, ПОФИКШЕН до PR): stored XSS через href ссылок. Новый статик-рендерер сначала подставлял
hrefизlink-марки как есть. Старый read-only путь (TipTapextension-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-паритету — проверены, регрессов нет.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>2f261511ectocb9c5dda59Отребейзил на актуальный 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: depsrenderComments—childrenByParent+page?.id(#340) плюсcanEdit(#338);ChildCommentsпробрасывает иchildrenByParent, иcanEdit.Объективка на отребейзенной голове: client comment+mention 81 passed / 81 (5 файлов — теперь включая тесты #338),
tsc --noEmitEXIT 0.Ревью — #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 — все с тривиальными фиксами.Открыто:
underline(StarterKit v3 его включает) → каждый подчёркнутый комментарий молча уходит в fallback-редактор (корректность цела, но perf-win теряется + нарушен собственный инвариант PR «набор зеркалит редактор»).keepMounted={false}на табах теряет недописанный черновик ответа и правку при переключении вкладок (было: обе панели смонтированы, черновик выживал).content-state — корректность теперь целиком на инвалидации кэша).comment-list-with-tabs.tsxноль тестов: группировкаchildrenByParent(ядро O(n)-рефактора) и активация ленивого редактора ответа без покрытия.Объективка запущена мной, зелёная (голова
cb9c5dda, реальный дифф 7 файлов против базы develop4369bbc5; кодер перебазировал ветку на текущий 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/needsF1 [regressions/coherence · warning] Добавь mark
underlineв статический рендер (или отключи его в редакторе) —comment-content-view.tsx:57-90(renderMarks) vscomment-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-onlyCommentEditor. Это не визуальный баг (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 })в редакторе, чтобы наборы реально совпали.F2 [stability/regressions · warning]
keepMounted={false}молча теряет черновик ответа и незавершённую правку при переключении вкладок —comment-list-with-tabs.tsx:212-217.Сверено по исходнику Mantine:
Tabsпо умолчаниюkeepMounted:true— до PR неактивная панель оставалась в DOM (display:none), теперь её поддерево полностью размонтируется. Сценарий: пользователь начал печатать ответ (ленивый редактор смонтирован,contentв локальном stateCommentEditorWithActions) → кликнул вкладку «Решённые» и вернулся → панель «Открытые» была размонтирована,mountedиcontentсброшены, черновик пропал. То же для правки (isEditing/editContentRefвCommentListItem). Замечу: perf-выигрыш здесь теперь МАЛ — тела уже статические, размонтирование скрытой вкладки экономит лишь рендер статического DOM, а ценой идёт молчаливая потеря пользовательского ввода.Fix: сохрани черновик/правку через переключение вкладок — например,
keepMounted={false}только на тяжёлой панели «Решённые» (per-panel проп), а «Открытые» держать смонтированной; либо вынести draft/edit-state надTabs(ключ по id комментария). Если размонтирование намеренно — ответьwontfix:с обоснованием, что потеря черновика приемлема.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, восстанавливает статическое тело).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— именно то, чем санитайзятся ссылки в редакторе комментариев (StarterKitlink: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и есть в depsrenderComments(memo↔suggestion-флоу цел);React.memoпропы стабильны (кэш точечно подменяет один комментарий);MentionContentвынесен чисто (NodeView-специфика не нужна); safe-valve делает дрейф схемы perf-only, не корректностным; ссылкиtarget=_blank rel="noopener noreferrer nofollow"= дефолты TiptapLink; двойная JSON-сериализация вhandleUpdateComment— pre-existing, не регресс PR.- 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>Починил все 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-panelkeepMountedперебивает родительскийfalse(сверено по исходнику Mantine 8TabsPanel.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) — оставил как есть, согласен с обоснованиями.📋 Объективка (прогнал на стенде)
comment-list-with-tabs.test.tsx);tsc -p apps/client/tsconfig.json --noEmit: EXIT 0.Ре-ревью — #349 (perf комментариев, #340), round 2. Вердикт: PASS ✅
Все 4 находки round 1 закрыты и сверены по коду; изменения ровно по фиксам (никаких неожиданных правок), round-1 DROP-калибровка кодером корректно не тронута.
case "underline": return <u>{acc}</u>вrenderMarks(зеркалит остальные марки) + тест на{type:"underline"}→<u>. Подчёркнутый коммент рисуется статически, а не деградирует в fallback.<Tabs keepMounted={false}>(тяжёлая «Решённые» размонтируется) +<Tabs.Panel value="open" keepMounted>— per-panel перебивает родительскийfalse(сверено по MantinekeepMounted || panelKeepMounted). Локальный state Open-панели (ленивый редактор +editContentRef/isEditing) не сбрасывается на Open→Resolved→Open; perf-микрооптимизация сохранена.edit -> save/cancel flow): (a) Save зовётmutateAsync({content: JSON.stringify(editContentRef)}), ре-рендер с новымcomment.contentпоказывает новое тело; (b) Cancel восстанавливает статическое тело без мутации; (c) сохранение после Cancel шлётcomment.content— доказывает, чтоeditContentRefочищен.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 отношения не имеет и в диффе не тронут. Готово к мержу.