diff --git a/packages/x-markdown/package.json b/packages/x-markdown/package.json index 37e632c9b6..8e31c756a8 100644 --- a/packages/x-markdown/package.json +++ b/packages/x-markdown/package.json @@ -52,11 +52,13 @@ "clsx": "^2.1.1", "dompurify": "^3.2.6", "html-react-parser": "^5.2.5", + "jsdom": "^26.1.0", "katex": "^0.16.22", "marked": "^15.0.12" }, "devDependencies": { "@types/dompurify": "^3.0.5", + "@types/jsdom": "^27.0.0", "@types/lodash.throttle": "^4.1.9", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", diff --git a/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts b/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts index 127b86e87c..e777b641a1 100644 --- a/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts +++ b/packages/x-markdown/src/XMarkdown/__test__/Renderer.test.ts @@ -1,6 +1,6 @@ -import DOMPurify from 'dompurify'; import React from 'react'; import Renderer from '../core/Renderer'; +import { getDOMPurify } from '../utils'; // Mock React components for testing const MockComponent: React.FC = (props) => { @@ -776,6 +776,7 @@ describe('Renderer', () => { const renderer = new Renderer({ components }); // Spy on DOMPurify.sanitize + const DOMPurify = getDOMPurify(); const sanitizeSpy = jest.spyOn(DOMPurify, 'sanitize'); const html = 'content'; @@ -807,6 +808,7 @@ describe('Renderer', () => { }); // Spy on DOMPurify.sanitize + const DOMPurify = getDOMPurify(); const sanitizeSpy = jest.spyOn(DOMPurify, 'sanitize'); const html = 'content'; diff --git a/packages/x-markdown/src/XMarkdown/core/Renderer.ts b/packages/x-markdown/src/XMarkdown/core/Renderer.ts index 7f1ca84406..c9c7fe0460 100644 --- a/packages/x-markdown/src/XMarkdown/core/Renderer.ts +++ b/packages/x-markdown/src/XMarkdown/core/Renderer.ts @@ -1,10 +1,10 @@ import type { Config as DOMPurifyConfig } from 'dompurify'; -import DOMPurify from 'dompurify'; import type { DOMNode, Element } from 'html-react-parser'; import parseHtml, { domToReact } from 'html-react-parser'; import React, { ReactNode } from 'react'; import AnimationText from '../AnimationText'; import type { ComponentProps, XMarkdownProps } from '../interface'; +import { getDOMPurify } from '../utils'; interface RendererOptions { components?: XMarkdownProps['components']; @@ -143,6 +143,8 @@ class Renderer { // Use DOMPurify to clean HTML while preserving custom components and target attributes const purifyConfig = this.configureDOMPurify(); + + const DOMPurify = getDOMPurify(); const cleanHtml = DOMPurify.sanitize(htmlString, purifyConfig); return parseHtml(cleanHtml, { diff --git a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts index 31d74bae05..f0113b754f 100644 --- a/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts +++ b/packages/x-markdown/src/XMarkdown/hooks/useStreaming.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { StreamCacheTokenType, XMarkdownProps } from '../interface'; +import { StreamCacheTokenType, StreamingOption, XMarkdownProps } from '../interface'; /* ------------ Type ------------ */ @@ -196,6 +196,96 @@ const safeEncodeURIComponent = (str: string): string => { } }; +/* ------------ Pure Processing Functions ------------ */ +const getIncompleteMarkdownPlaceholder = ( + cache: StreamCache, + incompleteMarkdownComponentMap?: StreamingOption['incompleteMarkdownComponentMap'], + components?: XMarkdownProps['components'], +): string | undefined => { + const { token, pending } = cache; + if (token === StreamCacheTokenType.Text) return; + /** + * An image tag starts with '!', if it's the only character, it's incomplete and should be stripped. + * ! + * ^ + */ + if (token === StreamCacheTokenType.Image && pending === '!') return undefined; + + /** + * If a table has more than two lines (header, separator, and at least one row), + * it's considered complete enough to not be replaced by a placeholder. + * | column1 | column2 |\n| -- | --|\n + * ^ + */ + if (token === StreamCacheTokenType.Table && pending.split('\n').length > 2) { + return pending; + } + + const componentMap = incompleteMarkdownComponentMap || {}; + const componentName = componentMap[token] || `incomplete-${token}`; + const encodedPending = safeEncodeURIComponent(pending); + + return components?.[componentName] + ? `<${componentName} data-raw="${encodedPending}" />` + : undefined; +}; + +const computeStreamingOutput = ( + text: string, + cache: StreamCache, + incompleteMarkdownComponentMap?: StreamingOption['incompleteMarkdownComponentMap'], + components?: XMarkdownProps['components'], +): string => { + if (!text) { + return ''; + } + + const expectedPrefix = cache.completeMarkdown + cache.pending; + // Reset cache if input doesn't continue from previous state + if (!text.startsWith(expectedPrefix)) { + Object.assign(cache, getInitialCache()); + } + + const chunk = text.slice(cache.processedLength); + if (!chunk) { + const incompletePlaceholder = getIncompleteMarkdownPlaceholder( + cache, + incompleteMarkdownComponentMap, + components, + ); + return cache.completeMarkdown + (incompletePlaceholder || ''); + } + + cache.processedLength += chunk.length; + const isTextInBlock = isInCodeBlock(text); + for (const char of chunk) { + cache.pending += char; + // Skip processing if inside code block + if (isTextInBlock) { + commitCache(cache); + continue; + } + + if (cache.token === StreamCacheTokenType.Text) { + for (const handler of recognizeHandlers) handler.recognize(cache); + } else { + const handler = recognizeHandlers.find((handler) => handler.tokenType === cache.token); + handler?.recognize(cache); + } + + if (cache.token === StreamCacheTokenType.Text) { + commitCache(cache); + } + } + + const incompletePlaceholder = getIncompleteMarkdownPlaceholder( + cache, + incompleteMarkdownComponentMap, + components, + ); + return cache.completeMarkdown + (incompletePlaceholder || ''); +}; + /* ------------ Main Hook ------------ */ const useStreaming = ( input: string, @@ -203,40 +293,27 @@ const useStreaming = ( ) => { const { streaming, components = {} } = config || {}; const { hasNextChunk: enableCache = false, incompleteMarkdownComponentMap } = streaming || {}; - const [output, setOutput] = useState(''); + const cacheRef = useRef(getInitialCache()); - const handleIncompleteMarkdown = useCallback( - (cache: StreamCache): string | undefined => { - const { token, pending } = cache; - if (token === StreamCacheTokenType.Text) return; - /** - * An image tag starts with '!', if it's the only character, it's incomplete and should be stripped. - * ! - * ^ - */ - if (token === StreamCacheTokenType.Image && pending === '!') return undefined; - - /** - * If a table has more than two lines (header, separator, and at least one row), - * it's considered complete enough to not be replaced by a placeholder. - * | column1 | column2 |\n| -- | --|\n - * ^ - */ - if (token === StreamCacheTokenType.Table && pending.split('\n').length > 2) { - return pending; - } + // Use lazy initializer to compute initial output synchronously on first render + const [output, setOutput] = useState(() => { + if (typeof input !== 'string') { + return ''; + } - const componentMap = incompleteMarkdownComponentMap || {}; - const componentName = componentMap[token] || `incomplete-${token}`; - const encodedPending = safeEncodeURIComponent(pending); + if (!enableCache) { + return input; + } - return components?.[componentName] - ? `<${componentName} data-raw="${encodedPending}" />` - : undefined; - }, - [incompleteMarkdownComponentMap, components], - ); + // For streaming mode, compute initial output synchronously to avoid empty content on mount + return computeStreamingOutput( + input, + cacheRef.current, + incompleteMarkdownComponentMap, + components, + ); + }); const processStreaming = useCallback( (text: string): void => { @@ -246,42 +323,16 @@ const useStreaming = ( return; } - const expectedPrefix = cacheRef.current.completeMarkdown + cacheRef.current.pending; - // Reset cache if input doesn't continue from previous state - if (!text.startsWith(expectedPrefix)) { - cacheRef.current = getInitialCache(); - } - - const cache = cacheRef.current; - const chunk = text.slice(cache.processedLength); - if (!chunk) return; - - cache.processedLength += chunk.length; - const isTextInBlock = isInCodeBlock(text); - for (const char of chunk) { - cache.pending += char; - // Skip processing if inside code block - if (isTextInBlock) { - commitCache(cache); - continue; - } - - if (cache.token === StreamCacheTokenType.Text) { - for (const handler of recognizeHandlers) handler.recognize(cache); - } else { - const handler = recognizeHandlers.find((handler) => handler.tokenType === cache.token); - handler?.recognize(cache); - } - - if (cache.token === StreamCacheTokenType.Text) { - commitCache(cache); - } - } + const result = computeStreamingOutput( + text, + cacheRef.current, + incompleteMarkdownComponentMap, + components, + ); - const incompletePlaceholder = handleIncompleteMarkdown(cache); - setOutput(cache.completeMarkdown + (incompletePlaceholder || '')); + setOutput(result); }, - [handleIncompleteMarkdown], + [incompleteMarkdownComponentMap, components], ); useEffect(() => { diff --git a/packages/x-markdown/src/XMarkdown/utils/getDOMPurify.ts b/packages/x-markdown/src/XMarkdown/utils/getDOMPurify.ts new file mode 100644 index 0000000000..f69ee44f21 --- /dev/null +++ b/packages/x-markdown/src/XMarkdown/utils/getDOMPurify.ts @@ -0,0 +1,43 @@ +import DOMPurify from 'dompurify'; + +let serverDOMPurify: ReturnType | null = null; +let jsdomInstance: any = null; + +function initializeServerDOMPurify(): boolean { + try { + const jsdomModule = require('jsdom'); + const JSDOM = jsdomModule.JSDOM || jsdomModule.default?.JSDOM || jsdomModule; + + if (!JSDOM) { + return false; + } + + jsdomInstance = new JSDOM(''); + serverDOMPurify = DOMPurify(jsdomInstance.window); + return true; + } catch { + return false; + } +} + +if (typeof window === 'undefined') { + initializeServerDOMPurify(); +} + +/** + * Returns the DOMPurify instance, compatible with both server-side (Node.js) and client-side (browser) environments. + * + * On the server, it creates a DOMPurify instance with a jsdom window; on the client, it returns the browser's DOMPurify. + * + * @see https://github.com/cure53/DOMPurify?tab=readme-ov-file#running-dompurify-on-the-server + */ +export function getDOMPurify(): ReturnType { + if (typeof window === 'undefined') { + if (!serverDOMPurify) { + initializeServerDOMPurify(); + } + return serverDOMPurify || DOMPurify; + } + + return DOMPurify; +} diff --git a/packages/x-markdown/src/XMarkdown/utils/index.ts b/packages/x-markdown/src/XMarkdown/utils/index.ts new file mode 100644 index 0000000000..5c94fd817f --- /dev/null +++ b/packages/x-markdown/src/XMarkdown/utils/index.ts @@ -0,0 +1 @@ +export { getDOMPurify } from './getDOMPurify';