From a2d98f5d3e4eafb2103771c7b5f2fef715b7efb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:47:42 +0100 Subject: [PATCH] fix(web): replace mermaid bomb-icon error flood with toast notifications (#594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../tiptap/tiptap-mermaid-extension.ts | 9 ++++ .../editors/tiptap/tiptap-plan-editor.tsx | 3 ++ .../components/shared/mermaid-block.test.tsx | 35 ++++++++++++++ apps/web/components/shared/mermaid-block.tsx | 12 ++++- .../shared/mermaid-error-toast.test.tsx | 46 +++++++++++++++++++ .../components/shared/mermaid-error-toast.tsx | 18 ++++++++ apps/web/components/shared/mermaid-utils.ts | 11 +++++ 7 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/shared/mermaid-error-toast.test.tsx create mode 100644 apps/web/components/shared/mermaid-error-toast.tsx diff --git a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts index 092c530b85..0ce31fd3f6 100644 --- a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts +++ b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts @@ -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); }); } diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx index 297253c433..9e6b48c92b 100644 --- a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -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); diff --git a/apps/web/components/shared/mermaid-block.test.tsx b/apps/web/components/shared/mermaid-block.test.tsx index ebe085b3c6..b132b4a0a8 100644 --- a/apps/web/components/shared/mermaid-block.test.tsx +++ b/apps/web/components/shared/mermaid-block.test.tsx @@ -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(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(); + }); +}); diff --git a/apps/web/components/shared/mermaid-block.tsx b/apps/web/components/shared/mermaid-block.tsx index 0c70f803fe..682f9eb440 100644 --- a/apps/web/components/shared/mermaid-block.tsx +++ b/apps/web/components/shared/mermaid-block.tsx @@ -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)), []); diff --git a/apps/web/components/shared/mermaid-error-toast.test.tsx b/apps/web/components/shared/mermaid-error-toast.test.tsx new file mode 100644 index 0000000000..019af23bd3 --- /dev/null +++ b/apps/web/components/shared/mermaid-error-toast.test.tsx @@ -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(); + }); +}); diff --git a/apps/web/components/shared/mermaid-error-toast.tsx b/apps/web/components/shared/mermaid-error-toast.tsx new file mode 100644 index 0000000000..a95e494ce0 --- /dev/null +++ b/apps/web/components/shared/mermaid-error-toast.tsx @@ -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]); +} diff --git a/apps/web/components/shared/mermaid-utils.ts b/apps/web/components/shared/mermaid-utils.ts index b00c205d6b..5dcfff18b7 100644 --- a/apps/web/components/shared/mermaid-utils.ts +++ b/apps/web/components/shared/mermaid-utils.ts @@ -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");