From 539989dcc5653459c712d622d0100d71e030b6f1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sun, 29 Mar 2026 21:55:17 -0400 Subject: [PATCH] fix(penpal): stabilize MarkdownViewer components to preserve mermaid SVGs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The components useMemo in MarkdownViewer depended on [highlights], causing new function references on every highlight change. React-Markdown treats component functions as element types, so changed references triggered unmount/remount of all custom components — destroying externally-rendered mermaid SVGs and their highlight overlays. Fix: store highlights in a ref and read from it in the code component, allowing useMemo to use [] (no deps) for stable function references. Co-Authored-By: Claude Opus 4.6 --- apps/penpal/ERD.md | 3 +++ apps/penpal/TESTING.md | 1 + .../src/components/MarkdownViewer.test.tsx | 22 +++++++++++++++++++ .../src/components/MarkdownViewer.tsx | 11 ++++++++-- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md index be7e76cf..8eb06d17 100644 --- a/apps/penpal/ERD.md +++ b/apps/penpal/ERD.md @@ -261,6 +261,9 @@ see-also: - **E-PENPAL-MD-RENDER**: Each rendered block is tagged with `data-source-line` (1-indexed). Heading IDs use the same slugification algorithm as Go's goldmark renderer. Mermaid blocks produce `.mermaid-container` divs with `data-mermaid-source`. ← [P-PENPAL-GFM](PRODUCT.md#P-PENPAL-GFM), [P-PENPAL-MERMAID](PRODUCT.md#P-PENPAL-MERMAID) +- **E-PENPAL-MD-STABLE-COMPONENTS**: MarkdownViewer's custom `components` object passed to ReactMarkdown must use stable function references across re-renders. The `code` component reads `highlights` through a ref instead of a closure so that `useMemo` has no dependencies. This prevents ReactMarkdown from unmounting/remounting custom components when highlights change, which would destroy externally-rendered mermaid SVGs and their highlight overlays. + ← [P-PENPAL-MERMAID](PRODUCT.md#P-PENPAL-MERMAID), [P-PENPAL-SVG-HIGHLIGHT](PRODUCT.md#P-PENPAL-SVG-HIGHLIGHT) + - **E-PENPAL-HOME-SIDEBAR**: In home mode (no active project), `Layout.tsx` renders a sidebar tree with: ⌂ header with view-options button and "+" add button, expandable workspace items (with child projects), standalone project items as top-level peers, and global "In Review" / "Recent" `NavLink`s in a global nav section. Visual dividers (`.home-section-divider`) separate the workspaces section, standalone projects section, and global navigation section; sections with no items are omitted along with their dividers. ← [P-PENPAL-HOME](PRODUCT.md#P-PENPAL-HOME), [P-PENPAL-HOME-TREE](PRODUCT.md#P-PENPAL-HOME-TREE) diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md index b09b72eb..64fb1c1c 100644 --- a/apps/penpal/TESTING.md +++ b/apps/penpal/TESTING.md @@ -70,6 +70,7 @@ see-also: | Git Integration (P-PENPAL-GIT-INFO) | — | — | — | — | | File List & Grouping (P-PENPAL-FILE-LIST) | — | ProjectPage.test.tsx | grouping_test.go, integration_test.go | — | | Markdown Rendering (P-PENPAL-GFM, MERMAID) | — | MarkdownViewer.test.tsx | — | mermaid-comments.spec.ts | +| Stable Components (E-PENPAL-MD-STABLE-COMPONENTS) | — | MarkdownViewer.test.tsx | — | — | | Text Selection & Anchors (P-PENPAL-SELECT-COMMENT, ANCHOR) | — | SelectionToolbar.test.tsx | — | review-workflow.spec.ts | | Anchor Resolution (P-PENPAL-ANCHOR-RESOLVE) | comments_test.go (implicit via round-trip) | rehypeCommentHighlights.test.ts | — | — | | Comment Highlights (P-PENPAL-HIGHLIGHT) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | review-workflow.spec.ts | diff --git a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx index 26bd5209..773a2fc5 100644 --- a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx +++ b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx @@ -142,6 +142,28 @@ describe('MarkdownViewer', () => { expect(codeMarks.length).toBeGreaterThan(0); }); + // E-PENPAL-MD-STABLE-COMPONENTS: mermaid containers survive highlight changes + it('preserves mermaid containers when highlights change', () => { + const md = '```mermaid\ngraph TD\n A --> B\n```\n\nSome text here'; + const { container, rerender } = render( + , + ); + const mermaidBefore = container.querySelector('.mermaid-container'); + expect(mermaidBefore).not.toBeNull(); + + // Re-render with highlights — mermaid container must be the same DOM node + const highlights = [ + { threadId: 't1', selectedText: 'Some text', startLine: 6 }, + ]; + rerender( + , + ); + const mermaidAfter = container.querySelector('.mermaid-container'); + expect(mermaidAfter).not.toBeNull(); + // Same DOM element reference means React reconciled in place (not unmounted/remounted) + expect(mermaidAfter).toBe(mermaidBefore); + }); + it('renders pending highlights with pending-highlight class', () => { const md = 'Hello world'; const highlights = [ diff --git a/apps/penpal/frontend/src/components/MarkdownViewer.tsx b/apps/penpal/frontend/src/components/MarkdownViewer.tsx index 28e687af..dfe5852f 100644 --- a/apps/penpal/frontend/src/components/MarkdownViewer.tsx +++ b/apps/penpal/frontend/src/components/MarkdownViewer.tsx @@ -176,6 +176,13 @@ const MarkdownViewer = forwardRef( function MarkdownViewer({ content, rawMarkdown: _rawMarkdown, onHeadingsExtracted, className, highlights }, ref) { const innerRef = useRef(null); + // E-PENPAL-MD-STABLE-COMPONENTS: keep highlights in a ref so the code + // component reads the latest value without being recreated on every change. + // This prevents ReactMarkdown from unmounting/remounting custom components + // (which would destroy externally-rendered mermaid SVGs). + const highlightsRef = useRef([]); + highlightsRef.current = highlights ?? []; + // Expose the inner ref to the parent useImperativeHandle(ref, () => innerRef.current!, []); @@ -244,7 +251,7 @@ const MarkdownViewer = forwardRef( // Find highlights targeting lines within this code block const codeHighlights: { selectedText: string; threadId: string; pending?: boolean; occurrenceIndex?: number }[] = - (highlights ?? []).filter(hl => { + highlightsRef.current.filter(hl => { if (!sourceLine) return false; // Code block spans from sourceLine (``` fence) to sourceLine + codeLineCount + 1 (closing ```) // Include fence line (>=) so anchors resolved to the opening ``` aren't silently dropped @@ -301,7 +308,7 @@ const MarkdownViewer = forwardRef( return
{children}
; }, }), - [highlights], + [], // E-PENPAL-MD-STABLE-COMPONENTS: stable references prevent mermaid SVG destruction ); return (