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 (