fix(export): comment.renderHTML returns a live jsdom node on the server, crashing export (#298) #299
Reference in New Issue
Block a user
Delete Branch "fix/298-export-comment"
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
Чинит #298: экспорт страницы/пространства (Markdown и HTML, оба через
jsonToHtml→generateHTML) падалExport failed:undefinedна любой странице с маркойcomment. closes #298.Причина.
comment.renderHTML(packages/editor-ext/src/lib/comment/comment.ts) при наличии глобальногоdocumentвозвращал ЖИВОЙ DOM-узел (document.createElement+ click-listener). На Node-сервере in-process MCP-модуль (packages/mcp/src/lib/collaboration.ts) инъектит jsdomglobal.window+global.document, поэтому старый guardtypeof document === "undefined"НЕ срабатывал → renderHTML отдавал jsdom-<span>. Серверный экспорт гоняет happy-domDOMSerializer, который крашится при вставке чужого jsdom-узла (NodeUtility.isInclusiveAncestor→Cannot read properties of undefined (reading 'length')).comment— единственное расширение, возвращавшее живой узел.Фикс. Расширил guard проверкой
isNodeRuntime(process.versions?.node): на любом Node-рантайме renderHTML возвращает сериализуемый spec-массив["span", attrs, 0], даже когда MCP инъектнул jsdom-глобалы. Браузерная ветка (click →ACTIVE_COMMENT_EVENT) не тронута — интерактивность комментариев в редакторе сохранена (Vite подставляет толькоprocess.envкак member-expression, объектаprocessв браузерном бандле нет →isNodeRuntimeтам false; проверено). MCP-зеркало (packages/mcp/src/lib/docmost-schema.ts) и так возвращает spec-массив и в пути экспорта не участвует (tiptapExtensionsимпортитCommentиз@docmost/editor-ext) — правка зеркала не нужна.Плюс диагностируемость:
export-modal.tsxтеперь читает реальный текст ошибки из Blob-тела (responseType:'blob'делалerr.response.data.messageвсегда undefined) — вместоExport failed:undefinedпоказывается сообщение сервера.How verified
apps/server/src/integrations/export/export-comment.spec.ts: инъектит jsdom-глобалы (как реальный сервер с MCP) и гоняет НАСТОЯЩИЙjsonToHtmlна документе с маркойcomment, ассертит успех +data-comment-id/class="comment-mark"(и resolved-кейс). Не-вакуозность доказана эмпирически: на непропатченном коде тест падает 2/2 с тем самым крашем happy-dom, после фикса — 2/2 pass.tsceditor-ext — 0; clienttsc— 0 по затронутым.process-полифилла в клиенте; guard — чистое расширение, тело spec-массива байт-в-байт, браузерная ветка не тронута; mcp-зеркало безопасно и вне пути экспорта).⚠️ Инфра-заметка для ревьюера: в воркдереве
node_modulesсимлинкнут на главный репозиторий, поэтому@docmost/editor-extрезолвится в СБОРКУ (dist) главного чекаута, а не в src ветки — для верного прогона серверного теста editor-ext надо пересобрать (CI это делает в pretest, src-правка на ветке). Клиент не затронут (Vite берётmodule→./src).Checklist
undefinedРевью — #299 (fix export: comment.renderHTML отдаёт живой jsdom-узел, крашит экспорт, closes #298), round 1, head
3f7e1bdc7, base develop (merge-based89650a45)Вердикт: CHANGES — фикс корректен, объективка зелёная, ключевой риск (browser false-positive) РАЗОБРАН и НЕ реализуется. Один DO: задокументировать браузер-безопасность guard'а (единственная load-bearing, НЕпокрытая тестом, невидимая инварианта, на которой держится вся корректность фикса). После — PASS.
Полный 9-аспектный веер. Объективка запущена мной (детач
3f7e1bdc,@docmost/editor-extПЕРЕСОБРАН — иначе воркдерево резолвит stale dist): editor-exttsc --build→ 0; serverjest export-comment→ 2 passed (repro #298 с инъекцией jsdom-глобалов + настоящийjsonToHtml); clienttsc→ 0. Не-вакуозность теста подтверждена структурно (ассертит на сам краш happy-dom, оба глобала инъектнуты → безisNodeRuntimeпадал бы).Подтверждено по коду
isNodeRuntime = typeof process !== "undefined" && !!process.versions?.node(comment.ts:181-182), OR'нут в SSR-проверку → на любом Node-рантайме (incl. MCP-инъекция jsdomglobal.document) renderHTML отдаёт сериализуемый["span", attrs, 0], не живой узел → happy-domDOMSerializerне крашится на чужом jsdom-узле. Форма guard'а ReferenceError-safe (typeof processкороткозамыкает доprocess.versions→ в браузере безprocessне бросает, а falsy).apps/client/vite.config.tsdefine'ит толькоprocess.env(member-expr), НЕТvite-plugin-node-polyfills/process-shim/electron;config.ts:107сам гейтит голыйprocess?.DEV-only. →typeof processв бандле"undefined"→isNodeRuntimefalse → браузерная click→ACTIVE_COMMENT_EVENTветка не тронута, интерактивность комментов сохранена.tiptapExtensions) — пофикшен; mcp-зеркало (docmost-schema.ts) и git-sync-копия УЖЕ отдают spec-массив и вне export-пути → правка не нужна. Сериализованный вывод (class="comment-mark"[ resolved],data-comment-id,data-resolved) байт-идентичен корректному серверному до-багу. XSS нет (happy-dom экранирует attrs, безопаснее старогоsetAttribute)..text()+JSON.parseв try/catch, деградирует в"", i18nt("Export failed")) — только error-путь, success не тронут, новых краш/реджектов нет.Do — apply these, then re-review
comment.ts:181-198. Существующий коммент отлично объясняет ПОЧЕМУ нуженisNodeRuntimeна Node-стороне (MCP инъектит jsdom →typeof documentнедостаточен). Но НЕ объясняет, почему guard БЕЗОПАСЕН в браузере — а именно на этом («в Vite-бандле нет объектаprocess») держится вся корректность фикса, и это НЕ покрыто ни одним тестом (client vitest вообще бежит под jsdom→node, гдеisNodeRuntimetrue — прод-браузер он не моделирует). Тихий провал: кто-то добавитvite-plugin-node-polyfills/process-shim →isNodeRuntimeстанет true в браузере → мёртвый spec вместо кликабельного узла → интерактивность комментов молча умрёт, ни один тест не поймает. Fix: добавь ~1 строку рядом с guard'ом, напр.: «Safe in the browser: Vite substitutes onlyprocess.env, not theprocessobject, sotypeof processis undefined in the client bundle and the interactive branch below still runs. Do NOT add a process polyfill (e.g. vite-plugin-node-polyfills) without revisiting this guard.»⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]low[test-coverage] диагностикаexport-modal.tsx(blob→JSON error, +19) без теста — diagnostic-only, нет data-пути; приемлемо.[style/linter]info[conventions]export-comment.spec.ts:1импортитjsdom, не объявленный вapps/serverdevDeps (резолвится черезshamefully-hoist) — работает, консистентно с hoisting-нормой репо; опц. объявить явно.[note]info[security/test] client vitest (vitest.config.tsenv jsdom→Node) даётisNodeRuntime=trueв клиентских unit-тестах → renderHTML вернёт голый span без click-listener; тест-env артефакт, не прод; клиентский тест, ассертящий comment-клик на марке, увидел бы голый span.Вне scope — кандидаты на отдельные issue (NOT part of this verdict)
renderHTML(comment.ts:200-224, click→ACTIVE_COMMENT_EVENT) — уникально среди editor-ext расширений (остальные держат интерактив в NodeView/plugin, renderHTML чистый). Перенести в существующийcommentDecoration-plugin / mark-view / editor-root-делегацию (ср.table-readonly-sort.ts:207) → renderHTML станет безусловно чистым, guard (и старый, и новый) удалится целиком. Долг, этим PR не требуется.docmost-schema.tscomment-зеркало хардкодитclass:"comment-mark", не эмититresolved/data-resolvedи не мержитHTMLAttributes→ MCP-HTML для resolved-коммента расходится с editor-ext. Вне crashing-пути, предшествует PR.responseType:"blob"эндпоинта (space-service.ts:65,page-service.ts:126) несут ту же латентную «message всегда undefined» ошибку, что чинит modal-хелпер.F1: fixed — коммит
4d8315da. Добавил коммент рядом с guard'ом, фиксирующий load-bearing browser-safety инвариант: Vite подставляет толькоprocess.env(member-expression), а не голый объектprocess, поэтому в клиентском бандлеtypeof process === "undefined"→isNodeRuntimefalse → интерактивная live-DOM ветка работает, комменты остаются кликабельными. Явно предупредил: НЕ добавлятьprocess-полифилл (напр. vite-plugin-node-polyfills) без пересмотра этого guard'а — иначе интерактивность комментов молча умрёт (тестом не покрыто, т.к. client vitest бежит под jsdom→node, где isNodeRuntime true). Только коммент, логика не менялась. review/needs.Ре-ревью — #299 (fix export: comment.renderHTML живой jsdom-узел, closes #298), round 2, head
4d8315da5, base develop (merge-based89650a45)Вердикт: PASS — F1 закрыт. Do-list пуст, эскалаций нет. Готов к мержу.
Round-2 дельта — ровно 8 строк
//-комментария у guard'аisNodeRuntime(comment.ts:181-188), логика НЕ тронута (git diff 3f7e1bdc..4d8315da— только добавленные строки комментария). Trivial-tier (comment-only) → полный код уже отревьюен в round 1 (9 аспектов, объективка зелёная); F1 закрыт прямой сверкой.Закрыто (сверено по коду + объективка)
process.env(member-expression), не голый объектprocess→ в клиентском бандлеtypeof process === "undefined"→isNodeRuntimefalse → интерактивная live-DOM ветка работает, комменты кликабельны. Явное предупреждение: НЕ добавлятьprocess-полифилл без пересмотра guard'а, иначе интерактивность молча умрёт; отмечено, что не покрыто тестом (client vitest бежит jsdom→node, где isNodeRuntime true). Текст точен и совпадает с тем, что security/regressions/coherence верифицировали в round 1.Объективка запущена мной (детач
4d8315da, editor-ext пересобран):tsc --buildeditor-ext → 0; serverjest export-comment→ 2 passed (repro #298). Код логики байт-идентичен round-1-голове (только коммент добавлен), объективка на новой голове зелёная.Напоминание оператору (follow-up issue из round 1, НЕ блокируют мерж): перенос comment-интерактивности из renderHTML в plugin/mark-view (удалит guard целиком); mcp-зеркало resolved-class расхождение; 2 других
responseType:blobэндпоинта с той же latent-ошибкой.