diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 2a83debf..274c18ed 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -14,6 +14,22 @@ import { notifications } from "@mantine/notifications"; import { exportSpace } from "@/features/space/services/space-service"; import { useTranslation } from "react-i18next"; +// The export request uses `responseType: "blob"`, so a server error body arrives +// as a Blob rather than parsed JSON — `err.response?.data.message` is therefore +// always undefined. Read and parse the blob to surface the real error message. +async function extractExportError(err: any): Promise { + 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 ?? ""; +} + interface ExportModalProps { id: string; type: "space" | "page"; @@ -52,8 +68,9 @@ export default function ExportModal({ }); onClose(); } catch (err) { + const message = await extractExportError(err); notifications.show({ - message: "Export failed:" + err.response?.data.message, + message: t("Export failed") + (message ? `: ${message}` : ""), color: "red", }); console.error("export error", err); diff --git a/apps/server/src/integrations/export/export-comment.spec.ts b/apps/server/src/integrations/export/export-comment.spec.ts new file mode 100644 index 00000000..7805f7ab --- /dev/null +++ b/apps/server/src/integrations/export/export-comment.spec.ts @@ -0,0 +1,82 @@ +import { JSDOM } from 'jsdom'; +import { jsonToHtml } from '../../collaboration/collaboration.util'; + +/** + * Regression test for issue #298: page/space export (Markdown/HTML) crashes on + * pages that contain inline comments. + * + * The in-process MCP module injects a jsdom `global.window` + `global.document` + * into the Node server (see packages/mcp/src/lib/collaboration.ts). Before the + * fix, the comment mark's `renderHTML` guard was only + * `typeof window === "undefined" || typeof document === "undefined"`, so with + * BOTH jsdom globals present it took the interactive browser branch and returned + * a LIVE jsdom node. The export path serializes via happy-dom's + * DOMSerializer, and appending a foreign jsdom node crashed happy-dom + * ("Cannot read properties of undefined (reading 'length')"). + * + * We reproduce the MCP-loaded server by injecting jsdom globals, then export a + * doc containing a comment mark and assert the serialization SUCCEEDS and emits + * the expected serializable . + * + * Non-vacuity: this test only exercises the buggy branch because BOTH jsdom + * `window` AND `document` are set below. If the `isNodeRuntime` condition is + * removed from the guard in packages/editor-ext/src/lib/comment/comment.ts, + * `renderHTML` returns a live jsdom node and `jsonToHtml` throws — this test + * then fails. (In a plain node env without the injected globals the guard's + * `typeof window === "undefined"` clause already short-circuits, so it is the + * injected globals that make this assertion meaningful.) + */ +describe('export with inline comments (issue #298)', () => { + const originalWindow = (global as any).window; + const originalDocument = (global as any).document; + + beforeAll(() => { + const dom = new JSDOM(''); + (global as any).window = dom.window; + (global as any).document = dom.window.document; + }); + + afterAll(() => { + (global as any).window = originalWindow; + (global as any).document = originalDocument; + }); + + const docWithComment = (resolved: boolean) => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + marks: [ + { + type: 'comment', + attrs: { commentId: 'c-123', resolved }, + }, + ], + text: 'commented text', + }, + ], + }, + ], + }); + + it('exports a page with an unresolved comment mark without crashing', () => { + let html: string; + expect(() => { + html = jsonToHtml(docWithComment(false)); + }).not.toThrow(); + + expect(html).toContain('data-comment-id="c-123"'); + expect(html).toContain('class="comment-mark"'); + expect(html).toContain('commented text'); + }); + + it('exports a resolved comment mark with the resolved class/attr', () => { + const html = jsonToHtml(docWithComment(true)); + expect(html).toContain('data-comment-id="c-123"'); + expect(html).toContain('comment-mark resolved'); + expect(html).toContain('data-resolved="true"'); + }); +}); diff --git a/packages/editor-ext/src/lib/comment/comment.ts b/packages/editor-ext/src/lib/comment/comment.ts index ec896357..7d59cef5 100644 --- a/packages/editor-ext/src/lib/comment/comment.ts +++ b/packages/editor-ext/src/lib/comment/comment.ts @@ -172,7 +172,28 @@ export const Comment = Mark.create({ const commentId = HTMLAttributes?.["data-comment-id"] || null; const resolved = HTMLAttributes?.["data-resolved"] || false; - if (typeof window === "undefined" || typeof document === "undefined") { + // 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. + // Safe in the browser: Vite substitutes only `process.env` (a member + // expression), NOT the bare `process` object, so `typeof process` is + // "undefined" in the client bundle → isNodeRuntime is false → the interactive + // live-DOM branch below still runs and comment marks stay clickable in the + // editor. This browser-safety is load-bearing and NOT covered by a test + // (client vitest runs under jsdom→node, where isNodeRuntime is true). Do NOT + // add a `process` polyfill (e.g. vite-plugin-node-polyfills) without + // revisiting this guard, or comment interactivity dies silently. + const isNodeRuntime = + typeof process !== "undefined" && !!process.versions?.node; + + if ( + typeof window === "undefined" || + typeof document === "undefined" || + isNodeRuntime + ) { return [ "span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {