From fbb0927401dc52f06942bfc4df3ef576deb3e6d6 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 30 Apr 2026 17:30:04 +0200 Subject: [PATCH] fix: preserve mermaid svg styles under tauri csp --- src/components/MermaidDiagram.test.tsx | 21 +++++++++++++++++++++ src/components/MermaidDiagram.tsx | 15 ++++++++++++++- tests/smoke/mermaid-diagram-notes.spec.ts | 11 +++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/components/MermaidDiagram.test.tsx b/src/components/MermaidDiagram.test.tsx index cb861de0..fd85d6f6 100644 --- a/src/components/MermaidDiagram.test.tsx +++ b/src/components/MermaidDiagram.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' import { MermaidDiagram } from './MermaidDiagram' const mermaidMock = vi.hoisted(() => ({ @@ -50,6 +51,26 @@ describe('MermaidDiagram', () => { expect(screen.getByTestId('mermaid-diagram-dialog-viewport').querySelector('svg')).not.toBeNull() }) + it('tags Mermaid SVG style elements with the runtime CSP nonce', async () => { + mermaidMock.render.mockResolvedValueOnce({ + svg: 'A to B', + }) + + render( + B'} + source={'```mermaid\nflowchart LR\nA --> B\n```'} + />, + ) + + await waitFor(() => { + expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('style')).not.toBeNull() + }) + + const style = screen.getByTestId('mermaid-diagram-viewport').querySelector('style') + expect(style?.getAttribute('nonce')).toBe(RUNTIME_STYLE_NONCE) + }) + it('falls back to the original source when Mermaid cannot render', async () => { mermaidMock.render.mockRejectedValueOnce(new Error('parse error')) diff --git a/src/components/MermaidDiagram.tsx b/src/components/MermaidDiagram.tsx index 6ca39809..0f76bc22 100644 --- a/src/components/MermaidDiagram.tsx +++ b/src/components/MermaidDiagram.tsx @@ -8,6 +8,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog' +import { RUNTIME_STYLE_NONCE } from '@/lib/runtimeStyleNonce' type MermaidApi = typeof import('mermaid')['default'] @@ -51,6 +52,18 @@ function initializeMermaid(mermaid: MermaidApi) { initialized = true } +function withRuntimeStyleNonce(svg: string): string { + if (!svg.includes(' { + style.setAttribute('nonce', RUNTIME_STYLE_NONCE) + }) + return container.innerHTML +} + async function renderMermaidDiagram({ diagram, renderId, @@ -62,7 +75,7 @@ async function renderMermaidDiagram({ const mermaid = (await import('mermaid')).default initializeMermaid(mermaid) const result = await mermaid.render(renderId, diagram) - return result.svg + return withRuntimeStyleNonce(result.svg) } const nextRender = renderQueue.then(render, render) renderQueue = nextRender.then(() => undefined, () => undefined) diff --git a/tests/smoke/mermaid-diagram-notes.spec.ts b/tests/smoke/mermaid-diagram-notes.spec.ts index 3a42eab4..78df2c39 100644 --- a/tests/smoke/mermaid-diagram-notes.spec.ts +++ b/tests/smoke/mermaid-diagram-notes.spec.ts @@ -3,6 +3,7 @@ import fs from 'fs' import path from 'path' import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' import { executeCommand, openCommandPalette } from './helpers' +import { RUNTIME_STYLE_NONCE } from '../../src/lib/runtimeStyleNonce' let tempVaultDir: string @@ -200,6 +201,13 @@ async function computedCss(locator: Locator, property: 'color' | 'fill' | 'strok ), property) } +async function readStyleNonce(svg: Locator): Promise { + return svg.locator('style').first().evaluate((style) => { + const nonce = (style as HTMLElement).nonce + return nonce.length > 0 ? nonce : style.getAttribute('nonce') + }) +} + async function labelFitsNode(node: Locator): Promise { return node.evaluate((element) => { const shape = element.querySelector('rect, polygon, circle, ellipse, path')! @@ -230,6 +238,7 @@ async function readReportedDiagramMetrics(page: Page, diagramIndex: number) { await labelFitsNode(mermaidNode(page, diagramIndex, 'Contract path')), await labelFitsNode(mermaidNode(page, diagramIndex, 'unscheduled clocking')), ].every(Boolean), + styleNonce: await readStyleNonce(svg), text: await svg.evaluate((element) => element.textContent ?? ''), } } @@ -248,6 +257,7 @@ test('Mermaid diagrams render when opening saved notes directly', async ({ page scheduledStroke: 'rgb(122, 91, 0)', scheduledTextColor: 'rgb(58, 44, 0)', labelsFit: true, + styleNonce: RUNTIME_STYLE_NONCE, text: expect.stringContaining('Linked to a planned shift?'), }) }) @@ -283,6 +293,7 @@ ${SYSTEM_OVERVIEW_DIAGRAM} scheduledStroke: 'rgb(122, 91, 0)', scheduledTextColor: 'rgb(58, 44, 0)', labelsFit: true, + styleNonce: RUNTIME_STYLE_NONCE, text: expect.stringContaining('Linked to a planned shift?'), }) await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toHaveCount(1)