-
Notifications
You must be signed in to change notification settings - Fork 3k
feat (web-shell): throttle Markdown AST parsing during streaming #7904
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
be1f3ef
a3c555f
a4f2e3d
10abf9d
83c9be1
906589c
974ea44
fd14b04
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ import { | |
| import { useTheme } from '../../themeContext'; | ||
| import { useTranscriptRenderMode } from '../../transcriptRenderMode'; | ||
| import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; | ||
| import type { Components } from 'react-markdown'; | ||
| import type { Components, Options } from 'react-markdown'; | ||
| import { isMarkdownFenceClosed } from '@datafe-open/markdown-chart'; | ||
| import remarkGfm from 'remark-gfm'; | ||
| import remarkMath from 'remark-math'; | ||
|
|
@@ -748,6 +748,74 @@ function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { | |
| return <img src={safeSrc} alt={alt || ''} className={styles.image} />; | ||
| } | ||
|
|
||
| /** | ||
| * Throttles a rapidly changing value (like a streaming string) to prevent | ||
| * O(n²) re-parsing of the entire Markdown AST on every token. | ||
| */ | ||
| function useThrottledValue( | ||
| value: string, | ||
| isStreaming: boolean | undefined, | ||
| intervalMs: number = 80, | ||
| ): string { | ||
| const [throttled, setThrottled] = useState(value); | ||
| const throttledRef = useRef(throttled); | ||
| throttledRef.current = throttled; | ||
| const lastRunRef = useRef(0); | ||
| const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| const valueRef = useRef(value); | ||
| valueRef.current = value; | ||
|
|
||
| useEffect(() => { | ||
| if (!isStreaming) { | ||
| if (timeoutRef.current) { | ||
| clearTimeout(timeoutRef.current); | ||
| timeoutRef.current = null; | ||
| } | ||
| // Flush immediately when streaming stops | ||
| if (throttledRef.current !== value) { | ||
| setThrottled(value); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| const now = Date.now(); | ||
| const elapsed = now - lastRunRef.current; | ||
|
|
||
| if (elapsed >= intervalMs) { | ||
| lastRunRef.current = now; | ||
| setThrottled(valueRef.current); | ||
| } else if (!timeoutRef.current) { | ||
| timeoutRef.current = setTimeout(() => { | ||
| lastRunRef.current = Date.now(); | ||
| timeoutRef.current = null; | ||
| setThrottled(valueRef.current); | ||
| }, intervalMs - elapsed); | ||
|
PratikWayase marked this conversation as resolved.
|
||
| } | ||
| }, [value, isStreaming, intervalMs]); | ||
|
|
||
| useEffect(() => { | ||
| return () => { | ||
| if (timeoutRef.current) { | ||
| clearTimeout(timeoutRef.current); | ||
| timeoutRef.current = null; | ||
| } | ||
| }; | ||
| }, []); | ||
|
|
||
| if (!isStreaming) return value; | ||
|
|
||
| // Bypass throttle for non-monotonic changes | ||
| if ( | ||
| typeof value === 'string' && | ||
| typeof throttled === 'string' && | ||
| !value.startsWith(throttled) | ||
| ) { | ||
| return value; | ||
| } | ||
|
Comment on lines
+808
to
+814
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The non-monotonic bypass branch in // render 'Hello world' (isStreaming), flush the throttle, then render
// 'Goodbye' (isStreaming) and assert 'Goodbye' appears immediately
// (without advancing timers)中文说明
— qwen3.8-max-preview via Qwen Code /review |
||
|
|
||
| return throttled; | ||
| } | ||
|
|
||
| // `code`/`pre`/`a`/`img` are stable references; only `table` is created per | ||
| // call (it closes over tableMode/tableResetKey). Recreating the components | ||
| // object for a table reset therefore never changes the `code` element type, so | ||
|
|
@@ -783,6 +851,35 @@ function createComponents( | |
|
|
||
| const COMPONENTS_DEFAULT = createComponents(); | ||
|
|
||
| /** | ||
| * Isolated memoized renderer. This ensures react-markdown ONLY re-parses | ||
| * when the throttled content or plugin references actually change. | ||
| */ | ||
| const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({ | ||
| content, | ||
| components, | ||
| remarkPlugins, | ||
| rehypePlugins, | ||
| urlTransform, | ||
| }: { | ||
| content: string; | ||
| components: Options['components']; | ||
| remarkPlugins: Options['remarkPlugins']; | ||
| rehypePlugins: Options['rehypePlugins']; | ||
| urlTransform: Options['urlTransform']; | ||
| }) { | ||
| return ( | ||
| <ReactMarkdown | ||
| components={components} | ||
| remarkPlugins={remarkPlugins} | ||
| rehypePlugins={rehypePlugins} | ||
| urlTransform={urlTransform} | ||
| > | ||
| {content} | ||
| </ReactMarkdown> | ||
| ); | ||
| }); | ||
|
|
||
| export const Markdown = memo(function Markdown({ | ||
| content, | ||
| source, | ||
|
|
@@ -792,19 +889,28 @@ export const Markdown = memo(function Markdown({ | |
| const { markdown, markdownTableMode } = useWebShellCustomization(); | ||
| const theme = useTheme(); | ||
| const sourceMarkdown = source ? markdown : undefined; | ||
| const renderedContent = | ||
| content && source && sourceMarkdown?.transformMarkdown | ||
| ? sourceMarkdown.transformMarkdown(content, { source }) | ||
| : content; | ||
|
|
||
| const throttledContent = useThrottledValue(content ?? '', isStreaming); | ||
| const renderedContent = useMemo( | ||
|
PratikWayase marked this conversation as resolved.
|
||
| () => | ||
| throttledContent && source && sourceMarkdown?.transformMarkdown | ||
| ? sourceMarkdown.transformMarkdown(throttledContent, { source }) | ||
| : throttledContent, | ||
| [throttledContent, source, sourceMarkdown], | ||
| ); | ||
|
|
||
| const effectiveTableMode = isStreaming | ||
| ? 'basic' | ||
| : (tableMode ?? markdownTableMode ?? 'basic'); | ||
|
|
||
| // Memoize components so references stay stable during throttle window | ||
| const components = useMemo(() => { | ||
| if (effectiveTableMode === 'advanced') { | ||
| return createComponents('advanced', renderedContent); | ||
| } | ||
| return COMPONENTS_DEFAULT; | ||
| }, [effectiveTableMode, renderedContent]); | ||
|
|
||
| const sourceComponents = sourceMarkdown?.components; | ||
| const renderedComponents = useMemo(() => { | ||
| if (!sourceComponents) return components; | ||
|
|
@@ -842,23 +948,29 @@ export const Markdown = memo(function Markdown({ | |
| [chartPre, renderedComponents], | ||
| ); | ||
|
|
||
| // Memoize plugins so their array references remain stable. | ||
| const remarkPlugins = useMemo(() => { | ||
| return sourceMarkdown?.remarkPlugins | ||
| ? [remarkGfm, remarkMath, ...sourceMarkdown.remarkPlugins] | ||
| : [remarkGfm, remarkMath]; | ||
| }, [sourceMarkdown?.remarkPlugins]); | ||
|
|
||
| const rehypePlugins = useMemo(() => { | ||
| return sourceMarkdown?.rehypePlugins | ||
| ? [rehypeKatex, ...sourceMarkdown.rehypePlugins] | ||
| : [rehypeKatex]; | ||
| }, [sourceMarkdown?.rehypePlugins]); | ||
|
|
||
| if (!content) return null; | ||
| const remarkPlugins = sourceMarkdown?.remarkPlugins | ||
| ? [remarkGfm, remarkMath, ...sourceMarkdown.remarkPlugins] | ||
| : [remarkGfm, remarkMath]; | ||
| const rehypePlugins = sourceMarkdown?.rehypePlugins | ||
| ? [rehypeKatex, ...sourceMarkdown.rehypePlugins] | ||
| : [rehypeKatex]; | ||
|
|
||
| const renderedMarkdown = ( | ||
| <ReactMarkdown | ||
| <MemoizedMarkdownRenderer | ||
| content={renderedContent} | ||
| components={componentsWithCharts} | ||
| remarkPlugins={remarkPlugins} | ||
| rehypePlugins={rehypePlugins} | ||
| components={componentsWithCharts} | ||
| urlTransform={markdownUrlTransform} | ||
| > | ||
| {renderedContent} | ||
| </ReactMarkdown> | ||
| /> | ||
| ); | ||
| const chartAwareMarkdown = chart ? ( | ||
| <WebShellMarkdownChartProvider | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.