From 4441995a2d9a9388794c2a86e60fd34362afff89 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 17:18:58 +0000 Subject: [PATCH] Handle markdown editor render crashes Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.test.tsx | 67 +++++++++++-- ui/src/components/MarkdownEditor.tsx | 112 ++++++++++++++-------- 2 files changed, 132 insertions(+), 47 deletions(-) diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 82bfeca97b1..b55b26db4c3 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { buildIssueReferenceHref, buildProjectMentionHref, buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; @@ -20,6 +20,7 @@ const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, emitMountParseError: false, emitMountSilentEmptyState: false, + throwOnRender: false, markdownValues: [] as string[], suppressHtmlProcessingValues: [] as boolean[], })); @@ -59,6 +60,9 @@ vi.mock("@mdxeditor/editor", async () => { }, forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>, ) { + if (mdxEditorMockState.throwOnRender) { + throw new Error("Rich editor render crashed"); + } mdxEditorMockState.markdownValues.push(markdown); mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing)); const [content, setContent] = React.useState(markdown); @@ -148,6 +152,14 @@ vi.mock("../lib/paste-normalization", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -155,13 +167,20 @@ async function flush() { } function createFileDragEvent(type: string) { - const event = new Event(type, { bubbles: true, cancelable: true }) as Event & { + const event = ( + typeof DragEvent === "function" + ? new DragEvent(type, { bubbles: true, cancelable: true }) + : new Event(type, { bubbles: true, cancelable: true }) + ) as Event & { dataTransfer: { types: string[]; files: File[]; dropEffect?: string }; }; - event.dataTransfer = { - types: ["Files"], - files: [], - }; + Object.defineProperty(event, "dataTransfer", { + configurable: true, + value: { + types: ["Files"], + files: [], + }, + }); return event; } @@ -223,6 +242,7 @@ describe("MarkdownEditor", () => { mdxEditorMockState.emitMountEmptyReset = false; mdxEditorMockState.emitMountParseError = false; mdxEditorMockState.emitMountSilentEmptyState = false; + mdxEditorMockState.throwOnRender = false; mdxEditorMockState.markdownValues = []; mdxEditorMockState.suppressHtmlProcessingValues = []; }); @@ -386,6 +406,35 @@ describe("MarkdownEditor", () => { }); }); + it("falls back to a raw textarea when the rich editor crashes during render", async () => { + mdxEditorMockState.throwOnRender = true; + const handleChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await vi.waitFor(() => { + expect(container.querySelector("textarea")).not.toBeNull(); + }); + const textarea = container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe("5. python3 circleback/sync_insights.py --input -- writes insights//*.md"); + expect(container.textContent).toContain("Rich editor unavailable for this markdown"); + expect(handleChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + it("falls back to a raw textarea when the rich editor mounts into the placeholder without callbacks", async () => { mdxEditorMockState.emitMountSilentEmptyState = true; const handleChange = vi.fn(); @@ -435,16 +484,18 @@ describe("MarkdownEditor", () => { const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null; expect(scope).not.toBeNull(); - act(() => { + await act(async () => { scope?.dispatchEvent(createFileDragEvent("dragenter")); }); + await flush(); expect(scope?.className).toContain("ring-1"); expect(container.textContent).toContain("Drop image to upload"); - act(() => { + await act(async () => { scope?.dispatchEvent(createFileDragEvent("dragleave")); }); + await flush(); expect(scope?.className).not.toContain("ring-1"); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index ac21745d706..37226d31eb2 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1,4 +1,5 @@ import { + Component, type ClipboardEvent, forwardRef, useCallback, @@ -11,6 +12,7 @@ import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type TouchEvent as ReactTouchEvent, + type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { @@ -93,6 +95,26 @@ export interface MarkdownEditorRef { insertMarkdown: (markdown: string) => void; } +class MarkdownEditorRichErrorBoundary extends Component< + { children: ReactNode; onError: (error: unknown) => void }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: unknown) { + this.props.onError(error); + } + + render() { + if (this.state.hasError) return null; + return this.props.children; + } +} + function readHtmlAttribute(attrs: string, name: string): string | null { const match = new RegExp(`${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs); return match?.[2] ?? match?.[3] ?? match?.[4] ?? null; @@ -176,6 +198,12 @@ function isSafeMarkdownLinkUrl(url: string): boolean { return !/^(javascript|data|vbscript):/i.test(trimmed); } +function richEditorErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "Rich editor failed to render"; +} + /* ---- Mention detection helpers ---- */ interface MentionState { @@ -1092,6 +1120,10 @@ export const MarkdownEditor = forwardRef ref.current.insertMarkdown(normalizeMarkdown(rawText)); }, []); + const handleRichEditorError = useCallback((error: unknown) => { + setRichEditorError(richEditorErrorMessage(error)); + }, []); + const mentionMenuPosition = mentionState ? computeMentionMenuPosition( mentionState, @@ -1258,47 +1290,49 @@ export const MarkdownEditor = forwardRef }} onPasteCapture={handlePasteCapture} > - { - if (readOnly) return; - const echo = echoIgnoreMarkdownRef.current; - if (echo !== null && next === echo) { - echoIgnoreMarkdownRef.current = null; - latestValueRef.current = next; - return; - } - if (echo !== null) { - echoIgnoreMarkdownRef.current = null; - } - - if (initialChildOnChangeRef.current) { - initialChildOnChangeRef.current = false; - if (next === "" && editorValue !== "") { - echoIgnoreMarkdownRef.current = editorValue; - ref.current?.setMarkdown(editorValue); + + { + if (readOnly) return; + const echo = echoIgnoreMarkdownRef.current; + if (echo !== null && next === echo) { + echoIgnoreMarkdownRef.current = null; + latestValueRef.current = next; return; } - } - latestValueRef.current = next; - onChange(next); - }} - onBlur={() => onBlur?.()} - onError={(payload) => { - setRichEditorError(payload.error); - }} - className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")} - contentEditableClassName={cn( - "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item", - contentClassName, - )} - additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]} - plugins={plugins} - /> + if (echo !== null) { + echoIgnoreMarkdownRef.current = null; + } + + if (initialChildOnChangeRef.current) { + initialChildOnChangeRef.current = false; + if (next === "" && editorValue !== "") { + echoIgnoreMarkdownRef.current = editorValue; + ref.current?.setMarkdown(editorValue); + return; + } + } + latestValueRef.current = next; + onChange(next); + }} + onBlur={() => onBlur?.()} + onError={(payload) => { + handleRichEditorError(payload.error); + }} + className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")} + contentEditableClassName={cn( + "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item", + contentClassName, + )} + additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]} + plugins={plugins} + /> + {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */} {mentionActive && filteredMentions.length > 0 && mentionMenuPosition &&