Handle markdown editor render crashes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 17:18:58 +00:00
1 parent 19cc090f70
commit 4441995a2d
2 files changed
+132 -47

No files matched your search

+59 -8
View File
@@ -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<void>) {
let result: void | Promise<void> = 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(
<MarkdownEditor
value="5. python3 circleback/sync_insights.py --input <tmp> -- writes insights/<group>/*.md"
onChange={handleChange}
placeholder="Markdown body"
/>,
);
});
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 <tmp> -- writes insights/<group>/*.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");
+73 -39
View File
@@ -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<MarkdownEditorRef, MarkdownEditorProps>
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<MarkdownEditorRef, MarkdownEditorProps>
}}
onPasteCapture={handlePasteCapture}
>
<MDXEditor
ref={setEditorRef}
markdown={editorValue}
suppressHtmlProcessing
placeholder={placeholder}
readOnly={readOnly}
onChange={(next) => {
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);
<MarkdownEditorRichErrorBoundary onError={handleRichEditorError}>
<MDXEditor
ref={setEditorRef}
markdown={editorValue}
suppressHtmlProcessing
placeholder={placeholder}
readOnly={readOnly}
onChange={(next) => {
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}
/>
</MarkdownEditorRichErrorBoundary>
{/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
{mentionActive && filteredMentions.length > 0 && mentionMenuPosition &&