[bug][export] Экспорт (Markdown/HTML) падает «Export failed:undefined» на страницах с комментариями: comment.renderHTML отдаёт чужой jsdom-узел при серверной сериализации (hap… #298

Closed
opened 2026-07-03 01:21:19 +03:00 by agent_vscode · 0 comments
Collaborator

Симптом

При экспорте страницы (формат по умолчанию — Markdown) в UI появляется уведомление:

Export failed:undefined

Файл не скачивается. В логах сервера:

ERR ... req={"method":"POST","url":"/api/pages/export", ...} context=ExceptionsHandler
err={"type":"TypeError","message":"Cannot read properties of undefined (reading 'length')",
"stack":"TypeError: Cannot read properties of undefined (reading 'length')
    at NodeUtility.isInclusiveAncestor (happy-dom@20.8.9/.../nodes/node/NodeUtility.js:35:53)
    at [appendChild] (happy-dom/.../nodes/node/Node.js:390:29)
    at [appendChild] (happy-dom/.../nodes/element/Element.js:1028:62)
    at HTMLParagraphElement.appendChild (happy-dom/.../nodes/node/Node.js:306:48)
    at prosemirror-model/dist/index.cjs:2725:19        (DOMSerializer.serializeFragment)
    at Fragment.forEach (prosemirror-model/dist/index.cjs:229:9)
    at DOMSerializer.serializeFragment (prosemirror-model/dist/index.cjs:2705:16)
    at DOMSerializer.serializeNodeInner (prosemirror-model/dist/index.cjs:2742:14) ..."}

Затронуто

  • Экспорт страницы и пространства, оба формата — Markdown и HTML (оба идут через jsonToHtmlgenerateHTML).
  • Падение только на страницах с маркой comment (комментарии/выделения). Страницы без комментариев экспортируются нормально — поэтому баг выглядит «плавающим».

Первопричина

  1. POST /api/pages/exportExportService.exportPage()jsonToHtml()generateHTML()getHTMLFromFragment() создаёт локальный happy-dom Window и сериализует документ через DOMSerializer.serializeFragment(doc, { document: happyDomDocument }).
  2. Марка comment (packages/editor-ext/src/lib/comment/comment.ts, renderHTML, строки ~171–211) при наличии глобального document идёт по «браузерной» ветке и создаёт живой DOM-узел: document.createElement("span") + addEventListener("click", …) — и возвращает этот узел.
  3. На сервере глобальный document присутствует, потому что in-process MCP-модуль (packages/mcp/src/lib/collaboration.ts, строки ~43–45) при загрузке делает:
    const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
    global.window = dom.window;
    global.document = dom.window.document;
    
    MCP подключён к серверу через McpModule в apps/server/src/app.module.ts.
  4. Guard typeof window === "undefined" || typeof document === "undefined" в comment.renderHTML не срабатывает (из-за jsdom-глобалов) → создаётся jsdom-узел <span>.
  5. DOMSerializer (работает поверх happy-dom-документа) вызывает happyDomParagraph.appendChild(jsdomSpan). Внутри happy-dom NodeUtility.isInclusiveAncestor(newNode, self) читает newNode[PropertySymbol.nodeArray].length, но у чужого (jsdom) узла нет happy-dom-свойства nodeArrayCannot read properties of undefined (reading 'length').

Итог: в одно DOM-дерево (happy-dom) вставляется узел из другой реализации (jsdom).

commentединственное расширение, чей renderHTML возвращает живой DOM-узел; остальные inline-ноды/марки (heading, status, spoiler, mention, …) возвращают безопасные spec-массивы ["span", attrs, 0].

Воспроизведение

Ручное:

  1. На странице выделить текст и добавить комментарий (появляется марка comment).
  2. Меню страницы → ExportMarkdown (или HTML) → Export.
  3. Уведомление Export failed:undefined, файл не скачан; в логах сервера — стек выше.

Изолированный воспроизводитель (документ с одной comment-маркой, серверные исходники через tsx):

  • С инжектом jsdom-глобалов (как реальный сервер с MCP) → EXPORT FAILED: Cannot read properties of undefined (reading 'length').
  • Без инжекта глобалов → EXPORT OK: <p ...>Hello <span data-comment-id="c1" class="comment-mark">commented</span> world</p>.

Предлагаемое исправление

1. Сервер (причина падения) — packages/editor-ext/src/lib/comment/comment.ts

Заставить renderHTML на Node всегда возвращать сериализуемый spec-массив, даже если MCP инжектнул jsdom-глобалы. Признак Node устойчив к инжекту global.document:

// The in-process MCP module injects a jsdom `global.document` into the Node
// server, so `typeof document === "undefined"` is not enough to detect SSR.
// On any Node runtime always return a plain, serializable spec array; the
// interactive live-DOM branch below is browser-only. This stops server-side
// HTML/Markdown export (happy-dom DOMSerializer) from appending a foreign
// jsdom node into a happy-dom tree.
const isNodeRuntime =
  typeof process !== "undefined" && !!process.versions?.node;

if (
  typeof window === "undefined" ||
  typeof document === "undefined" ||
  isNodeRuntime
) {
  return [
    "span",
    mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
      class: resolved ? "comment-mark resolved" : "comment-mark",
      "data-comment-id": commentId,
      ...(resolved && { "data-resolved": "true" }),
    }),
    0,
  ];
}

Браузерная ветка (клик → ACTIVE_COMMENT_EVENT, слушается в apps/client/src/features/editor/page-editor.tsx) не меняется — интерактивность комментариев в редакторе сохраняется.

2. Клиент (диагностируемость) — apps/client/src/components/common/export-modal.tsx

Запрос идёт с responseType: "blob", поэтому при ошибке тело приходит как Blob, и err.response?.data.message всегда undefined → «Export failed:undefined». Нужно прочитать реальный текст ошибки из blob (с фолбэком на err.message):

// With responseType 'blob', an error body arrives as a Blob, so
// err.response.data.message is always undefined. Read the Blob as text and
// parse the server's JSON error; fall back to a client-side error message.
async function extractExportError(err: any): Promise<string> {
  const data = err?.response?.data;
  if (data instanceof Blob) {
    try {
      const json = JSON.parse(await data.text());
      return json?.message ?? "";
    } catch {
      return "";
    }
  }
  return data?.message ?? err?.message ?? "";
}

// ...
catch (err) {
  const message = await extractExportError(err);
  notifications.show({
    message: t("Export failed") + (message ? `: ${message}` : ""),
    color: "red",
  });
  console.error("export error", err);
}

Затронутые файлы

  • packages/editor-ext/src/lib/comment/comment.ts — фикс renderHTML (основная причина).
  • apps/client/src/components/common/export-modal.tsx — показ реального текста ошибки вместо undefined.
  • Контекст (не менять): apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts, apps/server/src/integrations/export/export.service.ts, packages/mcp/src/lib/collaboration.ts.

Критерии приёмки

  • Экспорт страницы с комментариями в Markdown и HTML успешно скачивается — в т.ч. когда MCP-модуль загружен в процессе сервера.
  • Марка comment в экспортированном HTML сериализуется как <span data-comment-id="…" class="comment-mark">…</span>resolved-вариант).
  • Клик по комментарию в редакторе по-прежнему открывает панель комментария (нет регрессии интерактивности).
  • При реальной ошибке экспорта уведомление показывает текст с сервера, а не undefined.
  • Добавлен регрессионный тест: серверная сериализация документа с comment-маркой при установленном global.document (jsdom) не бросает и даёт ожидаемый HTML.

Связано

  • #293 — три копии схемы/конвертера (editor-ext / mcp / git-sync); тот же MCP-модуль и общий глобальный DOM.
  • Комментарий в apps/server/src/common/helpers/prosemirror/html/generateHTML.ts уже фиксирует факт утечки global.window из MCP-модуля.
## Симптом При экспорте страницы (формат по умолчанию — Markdown) в UI появляется уведомление: ``` Export failed:undefined ``` Файл не скачивается. В логах сервера: ``` ERR ... req={"method":"POST","url":"/api/pages/export", ...} context=ExceptionsHandler err={"type":"TypeError","message":"Cannot read properties of undefined (reading 'length')", "stack":"TypeError: Cannot read properties of undefined (reading 'length') at NodeUtility.isInclusiveAncestor (happy-dom@20.8.9/.../nodes/node/NodeUtility.js:35:53) at [appendChild] (happy-dom/.../nodes/node/Node.js:390:29) at [appendChild] (happy-dom/.../nodes/element/Element.js:1028:62) at HTMLParagraphElement.appendChild (happy-dom/.../nodes/node/Node.js:306:48) at prosemirror-model/dist/index.cjs:2725:19 (DOMSerializer.serializeFragment) at Fragment.forEach (prosemirror-model/dist/index.cjs:229:9) at DOMSerializer.serializeFragment (prosemirror-model/dist/index.cjs:2705:16) at DOMSerializer.serializeNodeInner (prosemirror-model/dist/index.cjs:2742:14) ..."} ``` ## Затронуто - Экспорт **страницы и пространства**, **оба формата — Markdown и HTML** (оба идут через `jsonToHtml` → `generateHTML`). - Падение только на **страницах с маркой `comment`** (комментарии/выделения). Страницы без комментариев экспортируются нормально — поэтому баг выглядит «плавающим». ## Первопричина 1. `POST /api/pages/export` → `ExportService.exportPage()` → `jsonToHtml()` → `generateHTML()` → `getHTMLFromFragment()` создаёт локальный **happy-dom** `Window` и сериализует документ через `DOMSerializer.serializeFragment(doc, { document: happyDomDocument })`. 2. Марка `comment` (`packages/editor-ext/src/lib/comment/comment.ts`, `renderHTML`, строки ~171–211) при наличии глобального `document` идёт по «браузерной» ветке и создаёт **живой** DOM-узел: `document.createElement("span")` + `addEventListener("click", …)` — и возвращает этот узел. 3. На сервере глобальный `document` присутствует, потому что in-process MCP-модуль (`packages/mcp/src/lib/collaboration.ts`, строки ~43–45) при загрузке делает: ```js const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>"); global.window = dom.window; global.document = dom.window.document; ``` MCP подключён к серверу через `McpModule` в `apps/server/src/app.module.ts`. 4. Guard `typeof window === "undefined" || typeof document === "undefined"` в `comment.renderHTML` **не срабатывает** (из-за jsdom-глобалов) → создаётся **jsdom**-узел `<span>`. 5. `DOMSerializer` (работает поверх **happy-dom**-документа) вызывает `happyDomParagraph.appendChild(jsdomSpan)`. Внутри happy-dom `NodeUtility.isInclusiveAncestor(newNode, self)` читает `newNode[PropertySymbol.nodeArray].length`, но у чужого (jsdom) узла нет happy-dom-свойства `nodeArray` → `Cannot read properties of undefined (reading 'length')`. Итог: в одно DOM-дерево (**happy-dom**) вставляется узел из другой реализации (**jsdom**). `comment` — **единственное** расширение, чей `renderHTML` возвращает живой DOM-узел; остальные inline-ноды/марки (`heading`, `status`, `spoiler`, `mention`, …) возвращают безопасные spec-массивы `["span", attrs, 0]`. ## Воспроизведение Ручное: 1. На странице выделить текст и добавить комментарий (появляется марка `comment`). 2. Меню страницы → **Export** → **Markdown** (или **HTML**) → **Export**. 3. Уведомление `Export failed:undefined`, файл не скачан; в логах сервера — стек выше. Изолированный воспроизводитель (документ с одной comment-маркой, серверные исходники через `tsx`): - **С** инжектом jsdom-глобалов (как реальный сервер с MCP) → `EXPORT FAILED: Cannot read properties of undefined (reading 'length')`. - **Без** инжекта глобалов → `EXPORT OK: <p ...>Hello <span data-comment-id="c1" class="comment-mark">commented</span> world</p>`. ## Предлагаемое исправление ### 1. Сервер (причина падения) — `packages/editor-ext/src/lib/comment/comment.ts` Заставить `renderHTML` на Node **всегда** возвращать сериализуемый spec-массив, даже если MCP инжектнул jsdom-глобалы. Признак Node устойчив к инжекту `global.document`: ```js // The in-process MCP module injects a jsdom `global.document` into the Node // server, so `typeof document === "undefined"` is not enough to detect SSR. // On any Node runtime always return a plain, serializable spec array; the // interactive live-DOM branch below is browser-only. This stops server-side // HTML/Markdown export (happy-dom DOMSerializer) from appending a foreign // jsdom node into a happy-dom tree. const isNodeRuntime = typeof process !== "undefined" && !!process.versions?.node; if ( typeof window === "undefined" || typeof document === "undefined" || isNodeRuntime ) { return [ "span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: resolved ? "comment-mark resolved" : "comment-mark", "data-comment-id": commentId, ...(resolved && { "data-resolved": "true" }), }), 0, ]; } ``` Браузерная ветка (клик → `ACTIVE_COMMENT_EVENT`, слушается в `apps/client/src/features/editor/page-editor.tsx`) не меняется — интерактивность комментариев в редакторе сохраняется. ### 2. Клиент (диагностируемость) — `apps/client/src/components/common/export-modal.tsx` Запрос идёт с `responseType: "blob"`, поэтому при ошибке тело приходит как `Blob`, и `err.response?.data.message` всегда `undefined` → «Export failed:undefined». Нужно прочитать реальный текст ошибки из blob (с фолбэком на `err.message`): ```js // With responseType 'blob', an error body arrives as a Blob, so // err.response.data.message is always undefined. Read the Blob as text and // parse the server's JSON error; fall back to a client-side error message. async function extractExportError(err: any): Promise<string> { const data = err?.response?.data; if (data instanceof Blob) { try { const json = JSON.parse(await data.text()); return json?.message ?? ""; } catch { return ""; } } return data?.message ?? err?.message ?? ""; } // ... catch (err) { const message = await extractExportError(err); notifications.show({ message: t("Export failed") + (message ? `: ${message}` : ""), color: "red", }); console.error("export error", err); } ``` ## Затронутые файлы - `packages/editor-ext/src/lib/comment/comment.ts` — фикс `renderHTML` (**основная причина**). - `apps/client/src/components/common/export-modal.tsx` — показ реального текста ошибки вместо `undefined`. - Контекст (не менять): `apps/server/src/common/helpers/prosemirror/html/getHTMLFromFragment.ts`, `apps/server/src/integrations/export/export.service.ts`, `packages/mcp/src/lib/collaboration.ts`. ## Критерии приёмки - [ ] Экспорт страницы с комментариями в **Markdown** и **HTML** успешно скачивается — в т.ч. когда MCP-модуль загружен в процессе сервера. - [ ] Марка `comment` в экспортированном HTML сериализуется как `<span data-comment-id="…" class="comment-mark">…</span>` (и `resolved`-вариант). - [ ] Клик по комментарию в редакторе по-прежнему открывает панель комментария (нет регрессии интерактивности). - [ ] При реальной ошибке экспорта уведомление показывает текст с сервера, а не `undefined`. - [ ] Добавлен регрессионный тест: серверная сериализация документа с `comment`-маркой при установленном `global.document` (jsdom) **не бросает** и даёт ожидаемый HTML. ## Связано - #293 — три копии схемы/конвертера (editor-ext / mcp / git-sync); тот же MCP-модуль и общий глобальный DOM. - Комментарий в `apps/server/src/common/helpers/prosemirror/html/generateHTML.ts` уже фиксирует факт утечки `global.window` из MCP-модуля.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#298