From 7477e21b021a3998758747634fff9cbeaaf76ae7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:00:21 +1000 Subject: [PATCH] fix(session-ui): upgrade DOMPurify and restore Mermaid rendering (#45773) --- bun.lock | 10 +- package.json | 2 +- .../component-tests/markdown.fixture.tsx | 3 + .../component-tests/markdown.spec.ts | 93 +++++++++++++++++++ .../component-tests/session-timeline.spec.ts | 28 ++++++ packages/session-ui/package.json | 2 +- .../src/components/markdown-cache.tsx | 10 +- .../session-ui/src/components/markdown.tsx | 18 ++++ .../src/timeline/mermaid.stories.tsx | 67 +++++++++++++ packages/ui/package.json | 2 +- 10 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 packages/session-ui/src/timeline/mermaid.stories.tsx diff --git a/bun.lock b/bun.lock index 34f00be6e44..e2b8abdfafc 100644 --- a/bun.lock +++ b/bun.lock @@ -730,7 +730,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "effect": "catalog:", "fuzzysort": "catalog:", "luxon": "catalog:", @@ -934,7 +934,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "fuzzysort": "catalog:", "katex": "0.16.47", "luxon": "catalog:", @@ -1136,7 +1136,7 @@ "ai": "6.0.168", "cross-spawn": "7.0.6", "diff": "8.0.4", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-169397b", "effect": "4.0.0-rc.111", @@ -3852,7 +3852,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -6606,8 +6606,6 @@ "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], - "mermaid/dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], - "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], diff --git a/package.json b/package.json index eb48e504aaf..85808e57252 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", - "dompurify": "3.3.1", + "dompurify": "3.4.14", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.5-169397b", "effect": "4.0.0-rc.111", diff --git a/packages/session-ui/component-tests/markdown.fixture.tsx b/packages/session-ui/component-tests/markdown.fixture.tsx index c31096f901b..0b534470efe 100644 --- a/packages/session-ui/component-tests/markdown.fixture.tsx +++ b/packages/session-ui/component-tests/markdown.fixture.tsx @@ -3,6 +3,9 @@ import { render } from "solid-js/web" import { Markdown } from "../src/components/markdown" import { preloadMarkdown } from "../src/components/markdown-cache" +export { sanitizeMarkdown } from "../src/components/markdown-cache" +export { renderMermaidSvg } from "../src/components/markdown-mermaid" + export async function mountMarkdown(options: { text: string; streaming?: boolean; cached?: boolean }) { if (options.cached) await preloadMarkdown(options.text, "markdown-test") const host = document.createElement("div") diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index 850fbd0ed1c..707f0581c40 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -8,6 +8,79 @@ story.beforeEach(async ({ mount }) => { await expect(root.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "") }) +story("sanitizes raw HTML while preserving supported Markdown markup", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { sanitizeMarkdown } = await import(fixture) + return [ + "
Safe formatting const x = 1
unsafe',
+ 'externallocal',
+ '',
+ "",
+ '',
+ ].map(sanitizeMarkdown)
+ }, fixture)
+ expect(result).toEqual([
+ "Safe formatting const x = 1
unsafe',
+ 'externallocal',
+ '',
+ "",
+ '',
+ ])
+})
+
+story("keeps Markdown sanitization and link protections after Mermaid renders", async ({ page }) => {
+ const result = await page.evaluate(async (fixture) => {
+ const { renderMermaidSvg, sanitizeMarkdown } = await import(fixture)
+ const html =
+ 'external
'
+ const before = sanitizeMarkdown(html)
+ const renders = []
+ for (const source of [
+ "flowchart LR\n A[Start] --> B[End]",
+ "sequenceDiagram\n Alice->>Bob: Hello",
+ '%%{init: {"flowchart": {"htmlLabels": true}}}%%\nflowchart LR\n A["External"] --> B[End]',
+ ]) {
+ const svg = await renderMermaidSvg(source)
+ const document = new DOMParser().parseFromString(svg, "image/svg+xml")
+ renders.push({
+ root: document.documentElement.localName,
+ text: document.documentElement.textContent,
+ unsafe: document.querySelectorAll('script, [onerror], [onload], [href^="javascript:"]').length,
+ links: Array.from(document.querySelectorAll("a")).map((link) => ({
+ href: link.getAttribute("href"),
+ target: link.getAttribute("target"),
+ rel: link.getAttribute("rel"),
+ })),
+ markdown: sanitizeMarkdown(html),
+ })
+ }
+ return {
+ before,
+ renders,
+ invalid: await renderMermaidSvg("not a diagram"),
+ afterInvalid: sanitizeMarkdown(html),
+ }
+ }, fixture)
+ expect(result.before).toBe(
+ 'external
',
+ )
+ expect(result.renders).toEqual([
+ { root: "svg", text: expect.stringContaining("Start"), unsafe: 0, links: [], markdown: result.before },
+ { root: "svg", text: expect.stringContaining("Hello"), unsafe: 0, links: [], markdown: result.before },
+ {
+ root: "svg",
+ text: expect.stringContaining("External"),
+ unsafe: 0,
+ links: [{ href: "https://example.com", target: "_blank", rel: "noopener" }],
+ markdown: result.before,
+ },
+ ])
+ expect(result.invalid).toBeUndefined()
+ expect(result.afterInvalid).toBe(result.before)
+})
+
story("mounts cached completed Markdown with sanitized HTML and decorations", async ({ page }) => {
await page.evaluate(
async ({ fixture, text }) => {
@@ -36,6 +109,7 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as
"noopener noreferrer",
)
await expect(markdown.locator("pre code")).toContainText("const answer = 42")
+ await expect(markdown.getByRole("button", { name: "Copy", exact: true })).toBeVisible()
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(0)
await harness.getByLabel("Markdown text").fill("## Replacement\n\n`new/file.ts`")
@@ -53,6 +127,25 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
})
+story("renders cached Mermaid blocks and falls back to code for invalid diagrams", async ({ page }) => {
+ await page.evaluate(async (fixture) => {
+ const { mountMarkdown } = await import(fixture)
+ await mountMarkdown({ text: "```mermaid\nflowchart LR\n A[Start] --> B[End]\n```", cached: true })
+ }, fixture)
+ const harness = page.getByTestId("markdown-fixture")
+ const markdown = harness.locator('[data-component="markdown"]')
+ await expect(markdown.locator('[data-component="markdown-mermaid"] > svg')).toBeVisible()
+ await harness.getByRole("button", { name: "Toggle Markdown" }).click()
+ await expect(markdown).toHaveCount(0)
+ await harness.getByRole("button", { name: "Toggle Markdown" }).click()
+ await expect(markdown.locator('[data-component="markdown-mermaid"] > svg')).toBeVisible()
+ await harness.getByLabel("Markdown text").fill("```mermaid\nnot a diagram\n```")
+ await expect(markdown.locator('[data-component="markdown-mermaid"]')).toHaveCount(0)
+ await expect(markdown.locator("pre code")).toBeVisible()
+ await expect(markdown.locator("pre code")).toHaveText("not a diagram")
+ await expect(markdown.getByRole("button", { name: "Copy", exact: true })).toBeVisible()
+})
+
story("keeps live elements and selection when a stream completes and later changes", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts
index e7007d8830a..378eb8e63fa 100644
--- a/packages/session-ui/component-tests/session-timeline.spec.ts
+++ b/packages/session-ui/component-tests/session-timeline.spec.ts
@@ -1,5 +1,33 @@
import { expect, story } from "../../storybook/playwright/story"
+for (const width of [1280, 390]) {
+ for (const streaming of [false, true]) {
+ story(
+ `renders Mermaid in the ${streaming ? "streaming" : "completed"} timeline at ${width}px`,
+ async ({ mount, page }) => {
+ await page.setViewportSize({ width, height: 900 })
+ const root = await mount("current-session-mermaid--diagrams", { args: { streaming } })
+ const timeline = root.locator('[data-component="session-timeline"]')
+ const diagrams = timeline.locator('[data-component="markdown-mermaid"] > svg')
+ await expect(diagrams).toHaveCount(2)
+ await expect(diagrams.nth(0)).toBeVisible()
+ await expect(diagrams.nth(0)).toContainText("Client")
+ await expect(diagrams.nth(1)).toBeVisible()
+ await expect(diagrams.nth(1)).toContainText("Send prompt")
+ await expect(timeline.locator('[data-mermaid-ready="true"]')).toHaveCount(2)
+ await expect(timeline.locator('[data-mermaid-ready="true"] > pre:visible')).toHaveCount(0)
+ if (streaming) {
+ await root.getByRole("button", { name: "Complete response" }).click()
+ await expect(timeline.locator('[data-markdown-complete="true"]')).toHaveCount(2)
+ await expect(diagrams).toHaveCount(2)
+ await expect(diagrams.nth(0)).toBeVisible()
+ await expect(diagrams.nth(1)).toBeVisible()
+ }
+ },
+ )
+ }
+}
+
story("renders streamed reasoning without starting the app", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json
index 1a6b0f9ee1d..1051d4ac564 100644
--- a/packages/session-ui/package.json
+++ b/packages/session-ui/package.json
@@ -74,7 +74,7 @@
"@solidjs/meta": "catalog:",
"diff": "catalog:",
"effect": "catalog:",
- "dompurify": "3.3.1",
+ "dompurify": "3.4.14",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "catalog:",
diff --git a/packages/session-ui/src/components/markdown-cache.tsx b/packages/session-ui/src/components/markdown-cache.tsx
index 80cb3fa9e04..9da44b82eee 100644
--- a/packages/session-ui/src/components/markdown-cache.tsx
+++ b/packages/session-ui/src/components/markdown-cache.tsx
@@ -10,6 +10,8 @@ export type MarkdownCacheEntry = {
const max = 200
const cache = new Map