mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Handle markdown editor render crashes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
19cc090f70
commit
4441995a2d
2 files changed
+132
-47
No files matched your search
@@ -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");
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in new issue
Block a user