fix(web): replace mermaid bomb-icon error flood with toast notifications (#594)
* fix(web): replace mermaid bomb-icon error flood with toast notifications mermaid.render() called without a container leaves orphaned error elements (with bomb icons) in document.body on parse failures. Clean up these orphans after every render and show an error toast instead. * fix(web): address PR review — prevent toast flooding, include error detail, add hook tests - Add latestRenderId guard in TipTap MermaidNodeView to skip stale renders, preventing toast spam on every keystroke - Include mermaid error message in toast description via CustomEvent detail - Add useMermaidErrorToast hook tests (event listener + cleanup) * style(web): format mermaid-error-toast test with prettier * fix(web): include error message in MermaidBlock toast description
This commit is contained in:
@@ -15,6 +15,8 @@ import {
|
||||
MAX_SCALE,
|
||||
getSvgDimensions,
|
||||
sanitizeMermaidCode,
|
||||
cleanupMermaidOrphans,
|
||||
emitMermaidRenderError,
|
||||
} from "@/components/shared/mermaid-utils";
|
||||
|
||||
/**
|
||||
@@ -112,6 +114,7 @@ class MermaidNodeView implements NodeView {
|
||||
private scale = DEFAULT_SCALE;
|
||||
private node: PmNode;
|
||||
private showingCode = false;
|
||||
private latestRenderId: string | null = null;
|
||||
|
||||
constructor(node: PmNode) {
|
||||
this.node = node;
|
||||
@@ -240,19 +243,25 @@ class MermaidNodeView implements NodeView {
|
||||
if (!mermaidInitialized) initMermaid();
|
||||
if (!code.trim()) return;
|
||||
const id = `mermaid-${++mermaidIdCounter}`;
|
||||
this.latestRenderId = id;
|
||||
const sanitizedCode = sanitizeMermaidCode(code);
|
||||
mermaid
|
||||
.render(id, sanitizedCode)
|
||||
.then(({ svg }) => {
|
||||
cleanupMermaidOrphans(id);
|
||||
if (id !== this.latestRenderId) return;
|
||||
this.svgContainer.innerHTML = svg;
|
||||
this.updateSizeWrapper();
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
cleanupMermaidOrphans(id);
|
||||
if (id !== this.latestRenderId) return;
|
||||
this.svgContainer.innerHTML = "";
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "mermaid-error";
|
||||
pre.textContent = `Error rendering diagram: ${err.message}`;
|
||||
this.svgContainer.appendChild(pre);
|
||||
emitMermaidRenderError(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { createCodeBlockWithMermaid } from "./tiptap-mermaid-extension";
|
||||
import { useMermaidErrorToast } from "@/components/shared/mermaid-error-toast";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import {
|
||||
CommentMark,
|
||||
@@ -370,6 +371,8 @@ export function TipTapPlanEditor(props: TipTapPlanEditorProps) {
|
||||
const { editor, editorRef, onSelectionChangeRef, onCommentClickRef, isReady, slash } =
|
||||
usePlanEditor(props);
|
||||
|
||||
useMermaidErrorToast();
|
||||
|
||||
useCommentClickHandler(wrapperRef, onCommentClickRef);
|
||||
useCommentShortcut(wrapperRef, editorRef, onSelectionChangeRef);
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MermaidBlock } from "./mermaid-block";
|
||||
import { cleanupMermaidOrphans } from "./mermaid-utils";
|
||||
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({ resolvedTheme: "dark" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: vi.fn(), updateToast: vi.fn(), dismissToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe("MermaidBlock", () => {
|
||||
it("renders a constrained outer container with a horizontal scroll viewport", () => {
|
||||
const html = renderToStaticMarkup(<MermaidBlock code={"graph LR\nA-->B"} />);
|
||||
@@ -16,3 +21,33 @@ describe("MermaidBlock", () => {
|
||||
expect(html).not.toContain("inline-block");
|
||||
});
|
||||
});
|
||||
|
||||
const RENDER_ID_1 = "mermaid-test-1";
|
||||
const RENDER_ID_2 = "mermaid-test-2";
|
||||
const D_RENDER_ID_2 = `d${RENDER_ID_2}`;
|
||||
|
||||
describe("cleanupMermaidOrphans", () => {
|
||||
it("removes elements matching the render id", () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = RENDER_ID_1;
|
||||
document.body.appendChild(el);
|
||||
|
||||
expect(document.getElementById(RENDER_ID_1)).not.toBeNull();
|
||||
cleanupMermaidOrphans(RENDER_ID_1);
|
||||
expect(document.getElementById(RENDER_ID_1)).toBeNull();
|
||||
});
|
||||
|
||||
it("removes elements matching the d-prefixed id", () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = D_RENDER_ID_2;
|
||||
document.body.appendChild(el);
|
||||
|
||||
expect(document.getElementById(D_RENDER_ID_2)).not.toBeNull();
|
||||
cleanupMermaidOrphans(RENDER_ID_2);
|
||||
expect(document.getElementById(D_RENDER_ID_2)).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing when no matching elements exist", () => {
|
||||
expect(() => cleanupMermaidOrphans("nonexistent-id")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
MAX_SCALE,
|
||||
getSvgDimensions,
|
||||
sanitizeMermaidCode,
|
||||
cleanupMermaidOrphans,
|
||||
} from "./mermaid-utils";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
|
||||
type MermaidAPI = typeof import("mermaid").default;
|
||||
|
||||
@@ -35,6 +37,7 @@ export function MermaidBlock({ code }: MermaidBlockProps) {
|
||||
const [svgSize, setSvgSize] = useState<{ w: number; h: number } | null>(null);
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!code.trim()) return;
|
||||
@@ -51,6 +54,7 @@ export function MermaidBlock({ code }: MermaidBlockProps) {
|
||||
return mermaid.render(id, sanitizedCode);
|
||||
})
|
||||
.then(({ svg }) => {
|
||||
cleanupMermaidOrphans(id);
|
||||
if (!cancelled && containerRef.current) {
|
||||
containerRef.current.innerHTML = svg;
|
||||
setSvgSize(getSvgDimensions(containerRef.current));
|
||||
@@ -58,13 +62,17 @@ export function MermaidBlock({ code }: MermaidBlockProps) {
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
cleanupMermaidOrphans(id);
|
||||
if (!cancelled) {
|
||||
setError(err.message);
|
||||
toast({ title: "Failed to render diagram", description: err.message, variant: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, resolvedTheme]);
|
||||
}, [code, resolvedTheme, toast]);
|
||||
|
||||
const zoomIn = useCallback(() => setScale((s) => Math.min(s + SCALE_STEP, MAX_SCALE)), []);
|
||||
const zoomOut = useCallback(() => setScale((s) => Math.max(s - SCALE_STEP, MIN_SCALE)), []);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useMermaidErrorToast } from "./mermaid-error-toast";
|
||||
import { MERMAID_ERROR_EVENT } from "./mermaid-utils";
|
||||
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: mockToast, updateToast: vi.fn(), dismissToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockToast.mockClear();
|
||||
});
|
||||
|
||||
describe("useMermaidErrorToast", () => {
|
||||
it("calls toast with error details when event fires", () => {
|
||||
const { unmount } = renderHook(() => useMermaidErrorToast());
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message: "Parse error at line 1" } }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledOnce();
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Failed to render diagram",
|
||||
description: "Parse error at line 1",
|
||||
variant: "error",
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("removes listener on unmount", () => {
|
||||
const { unmount } = renderHook(() => useMermaidErrorToast());
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message: "oops" } }));
|
||||
});
|
||||
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { MERMAID_ERROR_EVENT } from "./mermaid-utils";
|
||||
|
||||
export function useMermaidErrorToast(): void {
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const msg = (e as CustomEvent<{ message: string }>).detail?.message;
|
||||
toast({ title: "Failed to render diagram", description: msg, variant: "error" });
|
||||
};
|
||||
document.addEventListener(MERMAID_ERROR_EVENT, handler);
|
||||
return () => document.removeEventListener(MERMAID_ERROR_EVENT, handler);
|
||||
}, [toast]);
|
||||
}
|
||||
@@ -5,6 +5,17 @@ export const SCALE_STEP = 0.1;
|
||||
export const MIN_SCALE = 0.1;
|
||||
export const MAX_SCALE = 1.5;
|
||||
|
||||
export const MERMAID_ERROR_EVENT = "mermaid:render-error";
|
||||
|
||||
export function cleanupMermaidOrphans(id: string): void {
|
||||
document.getElementById(id)?.remove();
|
||||
document.getElementById(`d${id}`)?.remove();
|
||||
}
|
||||
|
||||
export function emitMermaidRenderError(message: string): void {
|
||||
document.dispatchEvent(new CustomEvent(MERMAID_ERROR_EVENT, { detail: { message } }));
|
||||
}
|
||||
|
||||
/** Read intrinsic width/height from an SVG element's viewBox or attributes. */
|
||||
export function getSvgDimensions(container: HTMLElement): { w: number; h: number } | null {
|
||||
const svg = container.querySelector("svg");
|
||||
|
||||
Reference in New Issue
Block a user