From 90c1f68e192e287df100bb9fb81148478458f57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 29 Apr 2026 15:40:24 +0200 Subject: [PATCH 1/3] Speed up Mermaid rendering (#4219) --- bun.lock | 10 +- packages/gitbook/package.json | 2 +- .../CodeBlock/MermaidCodeBlock.tsx | 195 ++++++++++++++---- 3 files changed, 166 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index f5c252073..375a42258 100644 --- a/bun.lock +++ b/bun.lock @@ -160,7 +160,7 @@ "mdast-util-gfm": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "memoizee": "^0.4.17", - "mermaid": "^11.12.3", + "mermaid": "^11.14.0", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", "motion": "^12.23.24", @@ -910,7 +910,7 @@ "@mermaid-js/mermaid-zenuml": ["@mermaid-js/mermaid-zenuml@0.2.2", "", { "dependencies": { "@zenuml/core": "^3.35.2" }, "peerDependencies": { "mermaid": "^10 || ^11" } }, "sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.17.5", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg=="], @@ -1722,6 +1722,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "@vercel/agent-readability": ["@vercel/agent-readability@0.2.1", "", { "peerDependencies": { "next": ">=14" }, "optionalPeers": ["next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-ShT7BzIS/dwKompii8tm5do+NR1g4xL5M3wM7S01xsH6yuYQ7wiTPZEcmHMFLHCsAQg45/mD0hgrufpS3NVunw=="], "@vercel/backends": ["@vercel/backends@0.0.53", "", { "dependencies": { "@vercel/build-utils": "13.12.0", "@vercel/nft": "1.5.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-f8JK9Jatlco6kT/NsLYVR88iqS7oZxUXzXW8TofLmyTT5o+OEXSY2hzAQSjul6r0qz/9yAhtQkMhcHG8Ne01ZQ=="], @@ -2122,7 +2124,7 @@ "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], @@ -2744,7 +2746,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="], + "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index be1947022..eb6372a11 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -52,7 +52,7 @@ "mdast-util-gfm": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "memoizee": "^0.4.17", - "mermaid": "^11.12.3", + "mermaid": "^11.14.0", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", "motion": "^12.23.24", diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx index a2dee7a00..71cc00106 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx @@ -3,9 +3,11 @@ import { useTheme } from 'next-themes'; import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport'; import { Loading } from '@/components/primitives/Loading'; import { tcls } from '@/lib/tailwind'; import Panzoom from '@panzoom/panzoom'; +import type { RenderResult } from 'mermaid'; import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock'; import { MermaidPanZoomControls } from './MermaidPanZoomControls'; import { getPlainCodeBlock } from './highlight'; @@ -14,8 +16,9 @@ import { getPlainCodeBlock } from './highlight'; * Used to render a Mermaid diagram from a CodeBlock. */ export function MermaidCodeBlock(props: ClientBlockProps) { - const { block, style } = props; + const { block, mode, style } = props; const source = getPlainCodeBlock(block); + const rootRef = useRef(null); const wrapperRef = useRef(null); const diagramRef = useRef(null); const [panZoom, setPanZoom] = useState | null>(null); @@ -24,8 +27,14 @@ export function MermaidCodeBlock(props: ClientBlockProps) { const { resolvedTheme } = useTheme(); const darkMode = resolvedTheme === 'dark'; const id = useSafeId(); + const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' }); + const shouldRender = mode === 'print' || hasBeenInViewport; useEffect(() => { + if (!shouldRender) { + return; + } + const container = diagramRef.current; const wrapper = wrapperRef.current; if (!container || !wrapper) { @@ -36,46 +45,63 @@ export function MermaidCodeBlock(props: ClientBlockProps) { let cleanupPanZoom: (() => void) | undefined; setError(false); setIsLoading(true); + setPanZoom(null); + container.innerHTML = ''; + + const cancelScheduledRender = scheduleMermaidWork(() => { + enqueueMermaidRender(async () => { + if (cancelled) { + return null; + } + + return renderMermaidDiagram({ + source, + id, + darkMode, + }); + }) + .then((result) => { + if (!result || cancelled) { + return; + } + + container.innerHTML = result.svg; + if (container.querySelector('svg')) { + result.bindFunctions?.(container); + } - renderMermaidDiagram({ - container, - source, - id, - darkMode, - }) - .then(() => { - if (!cancelled) { cleanupPanZoom = initPanzoom({ container, wrapper, onInit: setPanZoom, }); - } - }) - .catch(() => { - if (!cancelled) { - setError(true); - } - }) - .finally(() => { - if (!cancelled) { - setIsLoading(false); - } - }); + }) + .catch(() => { + if (!cancelled) { + setError(true); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + }); return () => { cancelled = true; + cancelScheduledRender(); cleanupPanZoom?.(); setPanZoom(null); }; - }, [source, id, darkMode]); + }, [source, id, darkMode, shouldRender]); if (error) { return ; } return ( -
+
{ + const { source, id, darkMode } = args; + const { mermaid } = await loadMermaid(); mermaid.initialize({ startOnLoad: false, @@ -120,11 +140,115 @@ async function renderMermaidDiagram(args: { theme: darkMode ? 'dark' : undefined, }); - const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container); - container.innerHTML = svg; - bindFunctions?.(container); + const renderContainer = createMermaidRenderContainer(); + + try { + return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer); + } finally { + renderContainer.remove(); + } } +/** + * Mermaid measures labels while rendering, so the temporary render target must be + * connected to the document. Keep it fixed and contained so those layout reads do + * not walk the visible document flow. + */ +function createMermaidRenderContainer() { + const container = document.createElement('div'); + + container.setAttribute('aria-hidden', 'true'); + Object.assign(container.style, { + contain: 'strict', + height: '100vh', + isolation: 'isolate', + left: '0', + overflow: 'hidden', + pointerEvents: 'none', + position: 'fixed', + top: '0', + visibility: 'hidden', + width: '100vw', + zIndex: '-1', + }); + + document.body.appendChild(container); + + return container; +} + +let mermaidLoadPromise: Promise<{ + mermaid: typeof import('mermaid')['default']; +}> | null = null; + +async function loadMermaid() { + if (!mermaidLoadPromise) { + mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')]) + .then(async ([{ default: mermaid }, { default: zenuml }]) => { + await mermaid.registerExternalDiagrams([zenuml]); + return { mermaid }; + }) + .catch((error) => { + mermaidLoadPromise = null; + throw error; + }); + } + + return mermaidLoadPromise; +} + +let mermaidRenderQueue = Promise.resolve(); + +function enqueueMermaidRender(task: () => Promise) { + const result = mermaidRenderQueue.then(task, task); + mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame); + + return result; +} + +function waitForNextFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +function scheduleMermaidWork(callback: () => void) { + const win = window as WindowWithIdleCallback; + let cancelled = false; + + if (win.requestIdleCallback && win.cancelIdleCallback) { + const handle = win.requestIdleCallback( + () => { + if (!cancelled) { + callback(); + } + }, + { timeout: 1500 } + ); + + return () => { + cancelled = true; + win.cancelIdleCallback?.(handle); + }; + } + + const handle = window.setTimeout(() => { + if (!cancelled) { + callback(); + } + }); + + return () => { + cancelled = true; + window.clearTimeout(handle); + }; +} + +type WindowWithIdleCallback = Window & { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (handle: number) => void; +}; + /** * Initialize panzoom on the diagram container. */ @@ -138,7 +262,6 @@ function initPanzoom(args: { const instance = Panzoom(container, { maxScale: 5, minScale: 0.5, - contain: 'outside', cursor: 'grab', panOnlyWhenZoomed: true, }); From 5e5075a13762a7948ce8d1bcff6bdc770a8c0b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 29 Apr 2026 17:13:31 +0200 Subject: [PATCH 2/3] Make Mermaid controls shown only on hover (#4221) --- .../CodeBlock/MermaidCodeBlock.tsx | 6 ++- .../CodeBlock/MermaidPanZoomControls.tsx | 54 +++++-------------- 2 files changed, 18 insertions(+), 42 deletions(-) diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx index 71cc00106..9e351a671 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx @@ -101,7 +101,11 @@ export function MermaidCodeBlock(props: ClientBlockProps) { } return ( -
+
+
{/* Row 1: empty, pan up, zoom in */}
); } From 83df84ac28498a136c115b7c686221f78400b089 Mon Sep 17 00:00:00 2001 From: conico974 Date: Wed, 29 Apr 2026 18:28:45 +0200 Subject: [PATCH 3/3] Improve markdown serving logic (#4224) Co-authored-by: Copilot --- bun.lock | 4 ++-- packages/gitbook/package.json | 2 +- packages/gitbook/src/middleware.ts | 10 ++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 375a42258..90b957682 100644 --- a/bun.lock +++ b/bun.lock @@ -140,7 +140,7 @@ "@sindresorhus/fnv1a": "^3.1.0", "@tailwindcss/container-queries": "^0.1.1", "@tusbar/cache-control": "^1.0.2", - "@vercel/agent-readability": "^0.2.1", + "@vercel/agent-readability": "^0.4.0", "ai": "^4.2.2", "assert-never": "catalog:", "bidc": "catalog:", @@ -1724,7 +1724,7 @@ "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], - "@vercel/agent-readability": ["@vercel/agent-readability@0.2.1", "", { "peerDependencies": { "next": ">=14" }, "optionalPeers": ["next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-ShT7BzIS/dwKompii8tm5do+NR1g4xL5M3wM7S01xsH6yuYQ7wiTPZEcmHMFLHCsAQg45/mD0hgrufpS3NVunw=="], + "@vercel/agent-readability": ["@vercel/agent-readability@0.4.0", "", { "peerDependencies": { "@sveltejs/kit": ">=2", "h3": ">=1.8 <2", "next": ">=14" }, "optionalPeers": ["@sveltejs/kit", "h3", "next"], "bin": { "agent-readability": "dist/cli/index.cjs" } }, "sha512-+qcRxDEbKKJzRlLqsZUnet63kepr8RikEOlT9pVEru/GNkiSinwZEJxyfUhdUpzav8El2v6DZybpyQuvlynAhQ=="], "@vercel/backends": ["@vercel/backends@0.0.53", "", { "dependencies": { "@vercel/build-utils": "13.12.0", "@vercel/nft": "1.5.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-f8JK9Jatlco6kT/NsLYVR88iqS7oZxUXzXW8TofLmyTT5o+OEXSY2hzAQSjul6r0qz/9yAhtQkMhcHG8Ne01ZQ=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index eb6372a11..f67cb7c6d 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -32,7 +32,7 @@ "@sindresorhus/fnv1a": "^3.1.0", "@tailwindcss/container-queries": "^0.1.1", "@tusbar/cache-control": "^1.0.2", - "@vercel/agent-readability": "^0.2.1", + "@vercel/agent-readability": "^0.4.0", "ai": "^4.2.2", "assert-never": "catalog:", "bidc": "catalog:", diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index c94f88f4e..cdf560a42 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -3,7 +3,7 @@ import { SiteInsightsDisplayContext, SiteInsightsLLMSVariant, } from '@gitbook/api'; -import { shouldServeMarkdown } from '@vercel/agent-readability'; +import { acceptsMarkdown, isAIAgent } from '@vercel/agent-readability'; import { cookies } from 'next/headers'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -775,7 +775,13 @@ function encodePathInSiteContent( default: { // If the pathname is a markdown file or the request is ing markdown, // we rewrite it to ~gitbook/markdown/:pathname - if (pathname.match(MARKDOWN_PATH_REGEX) || shouldServeMarkdown(request).serve) { + const aiAgentDetection = isAIAgent(request); + // Using heuristic detection incorrectly detects some legitimate bot requests as AI agents (e.g. Slackbot) + // We don't want to serve markdown for these requests as it can cause issues like breaking slack unfurling. + const shouldServeMarkdown = + (aiAgentDetection.detected && aiAgentDetection.method !== 'heuristic') || + acceptsMarkdown(request); + if (pathname.match(MARKDOWN_PATH_REGEX) || shouldServeMarkdown) { const pagePathWithoutMD = pathname.replace(MARKDOWN_PATH_REGEX, ''); const ask = new URL(request.url).searchParams.get('ask'); return {