From 817833c3a3357cfbeada15c62a421b2b81255629 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 9 Apr 2026 11:27:49 -0700 Subject: [PATCH] replace react markdown with streamdown --- .../features/components/features-preview.tsx | 28 +- .../changelog/components/timeline-list.tsx | 15 +- .../message/components/markdown-renderer.tsx | 44 +-- apps/sim/app/templates/[id]/template.tsx | 33 +- .../components/file-viewer/preview-panel.tsx | 120 +++---- .../components/chat-content/chat-content.tsx | 168 +++------ .../components/note-block/note-block.tsx | 321 +++++++++--------- apps/sim/hooks/use-streaming-reveal.ts | 113 ------ apps/sim/next.config.ts | 2 +- apps/sim/package.json | 7 +- bun.lock | 175 +++++++++- 11 files changed, 466 insertions(+), 560 deletions(-) delete mode 100644 apps/sim/hooks/use-streaming-reveal.ts diff --git a/apps/sim/app/(landing)/components/features/components/features-preview.tsx b/apps/sim/app/(landing)/components/features/components/features-preview.tsx index e485396a7e..ae603009bc 100644 --- a/apps/sim/app/(landing)/components/features/components/features-preview.tsx +++ b/apps/sim/app/(landing)/components/features/components/features-preview.tsx @@ -2,8 +2,8 @@ import { type SVGProps, useEffect, useRef, useState } from 'react' import { AnimatePresence, motion, useInView } from 'framer-motion' -import ReactMarkdown, { type Components } from 'react-markdown' -import remarkGfm from 'remark-gfm' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { ChevronDown } from '@/components/emcn' import { Database, File, Library, Table } from '@/components/emcn/icons' import { @@ -557,8 +557,8 @@ The team agreed to prioritize the new onboarding flow. Key decisions: Follow up with engineering on the timeline for the API v2 migration. Draft the proposal for the board meeting next week.` -const MD_COMPONENTS: Components = { - h1: ({ children }) => ( +const MD_COMPONENTS = { + h1: ({ children }: { children?: React.ReactNode }) => (

), - h2: ({ children }) => ( + h2: ({ children }: { children?: React.ReactNode }) => (

{children}

), - ul: ({ children }) => , - ol: ({ children }) =>
    {children}
, - li: ({ children }) => ( + ul: ({ children }: { children?: React.ReactNode }) => ( + + ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
    {children}
+ ), + li: ({ children }: { children?: React.ReactNode }) => (
  • {children}
  • ), - p: ({ children }) =>

    {children}

    , + p: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), } function MockFullFiles() { @@ -618,9 +624,9 @@ function MockFullFiles() { transition={{ duration: 0.4, delay: 0.5 }} >
    - + {source} - +
    diff --git a/apps/sim/app/changelog/components/timeline-list.tsx b/apps/sim/app/changelog/components/timeline-list.tsx index e940bb98bb..26c9119734 100644 --- a/apps/sim/app/changelog/components/timeline-list.tsx +++ b/apps/sim/app/changelog/components/timeline-list.tsx @@ -1,7 +1,8 @@ 'use client' import React from 'react' -import ReactMarkdown from 'react-markdown' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn' import type { ChangelogEntry } from '@/app/changelog/components/changelog-content' @@ -141,7 +142,8 @@ export default function ChangelogList({ initialEntries }: Props) {
    - isContributorsLabel(children) ? null : ( @@ -192,11 +194,8 @@ export default function ChangelogList({ initialEntries }: Props) { {children} ), - code: ({ children, ...props }) => ( - + inlineCode: ({ children }) => ( + {children} ), @@ -212,7 +211,7 @@ export default function ChangelogList({ initialEntries }: Props) { }} > {cleanMarkdown(entry.content)} - +
    ))} diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx index a62c901ae2..d2b45a8e09 100644 --- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx +++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx @@ -1,6 +1,7 @@ import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' +import { code } from '@streamdown/code' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { Tooltip } from '@/components/emcn' import { CopyCodeButton } from '@/components/ui/copy-code-button' import { extractTextContent } from '@/lib/core/utils/react-node-text' @@ -25,7 +26,7 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re ) } -const REMARK_PLUGINS = [remarkGfm] +const STREAMDOWN_PLUGINS = { code } function createCustomComponents(LinkComponent: typeof LinkWithPreview) { return { @@ -72,11 +73,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) { {children} ), - li: ({ - children, - ordered, - ...props - }: React.LiHTMLAttributes & { ordered?: boolean }) => ( + li: ({ children }: React.LiHTMLAttributes) => (
  • {children}
  • @@ -116,28 +113,11 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) { ) }, - code: ({ - inline, - className, - children, - ...props - }: React.HTMLAttributes & { className?: string; inline?: boolean }) => { - if (inline) { - return ( - - {children} - - ) - } - return ( - - {children} - - ) - }, + inlineCode: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), blockquote: ({ children }: React.HTMLAttributes) => (
    @@ -215,9 +195,9 @@ const MarkdownRenderer = memo(function MarkdownRenderer({ return (
    - + {processedContent} - +
    ) }) diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index 40ad9722a8..b311837cdd 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -14,7 +14,8 @@ import { User, } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' -import ReactMarkdown from 'react-markdown' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { Breadcrumb, Button, @@ -875,7 +876,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template About this Workflow
    - (

    @@ -913,16 +915,16 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template ), li: ({ children }) =>

  • {children}
  • , - code: ({ inline, children }: any) => - inline ? ( - - {children} - - ) : ( - - {children} - - ), + inlineCode: ({ children }) => ( + + {children} + + ), + code: ({ children }) => ( + + {children} + + ), a: ({ href, children }) => ( {template.details.about} -
    +
    )} @@ -1056,7 +1058,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template {/* Creator bio */} {template.creator.details?.about && (
    - (

    @@ -1081,7 +1084,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template }} > {template.creator.details.about} - +

    )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 2e77eae3eb..0c9867b85b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -1,19 +1,22 @@ 'use client' import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react' +import { code } from '@streamdown/code' import { useRouter } from 'next/navigation' -import type { Components, ExtraProps } from 'react-markdown' -import ReactMarkdown from 'react-markdown' import rehypeSlug from 'rehype-slug' import remarkBreaks from 'remark-breaks' -import remarkGfm from 'remark-gfm' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { Checkbox } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useAutoScroll } from '@/hooks/use-auto-scroll' -import { useStreamingReveal } from '@/hooks/use-streaming-reveal' import { DataTable } from './data-table' +interface HastNode { + position?: { start?: { offset?: number } } +} + type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null const PREVIEWABLE_MIME_TYPES: Record = { @@ -72,8 +75,9 @@ export const PreviewPanel = memo(function PreviewPanel({ return null }) -const REMARK_PLUGINS = [remarkGfm, remarkBreaks] +const REMARK_PLUGINS = [remarkBreaks] const REHYPE_PLUGINS = [rehypeSlug] +const STREAMDOWN_PLUGINS = { code } /** * Carries the contentRef and toggle handler from MarkdownPreview down to the @@ -127,34 +131,11 @@ const STATIC_MARKDOWN_COMPONENTS = { {children} ), - code: ({ - className, - children, - node: _node, - ...props - }: React.HTMLAttributes & ExtraProps) => { - const isInline = !className?.includes('language-') - - if (isInline) { - return ( - - {children} - - ) - } - - return ( - - {children} - - ) - }, + inlineCode: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), pre: ({ children }: { children?: React.ReactNode }) => <>{children}, strong: ({ children }: { children?: React.ReactNode }) => ( {children} @@ -168,8 +149,13 @@ const STATIC_MARKDOWN_COMPONENTS = {
    ), hr: () =>
    , - img: ({ src, alt, node: _node }: React.ComponentPropsWithoutRef<'img'> & ExtraProps) => ( - {alt + img: ({ src, alt }: React.ImgHTMLAttributes) => ( + {alt ), table: ({ children }: { children?: React.ReactNode }) => (
    @@ -193,7 +179,7 @@ const STATIC_MARKDOWN_COMPONENTS = { ), } -function UlRenderer({ className, children }: React.ComponentPropsWithoutRef<'ul'> & ExtraProps) { +function UlRenderer({ className, children }: { className?: string; children?: React.ReactNode }) { const isTaskList = typeof className === 'string' && className.includes('contains-task-list') return (
      & ExtraProps) { +function OlRenderer({ className, children }: { className?: string; children?: React.ReactNode }) { const isTaskList = typeof className === 'string' && className.includes('contains-task-list') return (
        & ExtraProps) { +}: { + className?: string + children?: React.ReactNode + node?: HastNode +}) { const ctx = useContext(MarkdownCheckboxCtx) const isTaskItem = typeof className === 'string' && className.includes('task-list-item') @@ -249,12 +239,7 @@ function LiRenderer({ return
      1. {children}
      2. } -function InputRenderer({ - type, - checked, - node: _node, - ...props -}: React.ComponentPropsWithoutRef<'input'> & ExtraProps) { +function InputRenderer({ type, checked, ...props }: React.ComponentPropsWithoutRef<'input'>) { const ctx = useContext(MarkdownCheckboxCtx) const index = useContext(CheckboxIndexCtx) @@ -348,7 +333,7 @@ const MARKDOWN_COMPONENTS = { ol: OlRenderer, li: LiRenderer, input: InputRenderer, -} satisfies Components +} const MarkdownPreview = memo(function MarkdownPreview({ content, @@ -361,7 +346,6 @@ const MarkdownPreview = memo(function MarkdownPreview({ }) { const { push: navigate } = useRouter() const { ref: scrollRef } = useAutoScroll(isStreaming) - const { committed, incoming, generation } = useStreamingReveal(content, isStreaming) const contentRef = useRef(content) contentRef.current = content @@ -387,32 +371,20 @@ const MarkdownPreview = memo(function MarkdownPreview({ } }, [content]) - const committedMarkdown = useMemo( - () => - committed ? ( - - {committed} - - ) : null, - [committed] - ) - if (onCheckboxToggle) { return (
        - {content} - +
        @@ -422,21 +394,15 @@ const MarkdownPreview = memo(function MarkdownPreview({ return (
        - {committedMarkdown} - {incoming && ( -
        :first-child]:mt-0')} - > - - {incoming} - -
        - )} + + {content} +
        ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 018967deae..2ba6cd922f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -1,40 +1,18 @@ 'use client' -import { Children, type ComponentPropsWithoutRef, isValidElement, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import 'prismjs/components/prism-typescript' -import 'prismjs/components/prism-bash' -import 'prismjs/components/prism-css' -import 'prismjs/components/prism-markup' -import '@/components/emcn/components/code/code.css' -import { Checkbox, highlight, languages } from '@/components/emcn' -import { CopyCodeButton } from '@/components/ui/copy-code-button' +import { type ComponentPropsWithoutRef, useMemo } from 'react' +import { code } from '@streamdown/code' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' +import { Checkbox } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' -import { extractTextContent } from '@/lib/core/utils/react-node-text' import { PendingTagIndicator, parseSpecialTags, SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' -import { useStreamingReveal } from '@/hooks/use-streaming-reveal' import { useStreamingText } from '@/hooks/use-streaming-text' -const REMARK_PLUGINS = [remarkGfm] - -const LANG_ALIASES: Record = { - js: 'javascript', - ts: 'typescript', - tsx: 'typescript', - jsx: 'javascript', - sh: 'bash', - shell: 'bash', - html: 'markup', - xml: 'markup', - yml: 'yaml', - py: 'python', -} - const PROSE_CLASSES = cn( 'prose prose-base dark:prose-invert max-w-none', 'font-[family-name:var(--font-inter)] antialiased break-words font-[430] tracking-[0]', @@ -55,8 +33,10 @@ const PROSE_CLASSES = cn( type TdProps = ComponentPropsWithoutRef<'td'> type ThProps = ComponentPropsWithoutRef<'th'> -const MARKDOWN_COMPONENTS: React.ComponentProps['components'] = { - table({ children }) { +const STREAMDOWN_PLUGINS = { code } + +const MARKDOWN_COMPONENTS = { + table({ children }: { children?: React.ReactNode }) { return (
        @@ -65,7 +45,7 @@ const MARKDOWN_COMPONENTS: React.ComponentProps['component ) }, - thead({ children }) { + thead({ children }: { children?: React.ReactNode }) { return {children} }, th({ children, style }: ThProps) { @@ -88,52 +68,7 @@ const MARKDOWN_COMPONENTS: React.ComponentProps['component ) }, - pre({ children }) { - let codeString = '' - let language = '' - - for (const child of Children.toArray(children)) { - if (isValidElement(child) && child.type === 'code') { - const props = child.props as { className?: string; children?: React.ReactNode } - codeString = extractTextContent(props.children) - if (props.className?.startsWith('language-')) { - language = props.className.slice(9) - } - break - } - } - - if (!codeString) { - return ( -
        -          {children}
        -        
        - ) - } - - const resolved = LANG_ALIASES[language] || language || 'javascript' - const grammar = languages[resolved] || languages.javascript - const html = highlight(codeString.trimEnd(), grammar, resolved) - - return ( -
        -
        - {language || 'code'} - -
        -
        -
        -        
        -
        - ) - }, - a({ children, href }) { + a({ children, href }: { children?: React.ReactNode; href?: string }) { return ( ['component ) }, - ul({ children, className }) { + ul({ children, className }: { children?: React.ReactNode; className?: string }) { if (className?.includes('contains-task-list')) { return
          {children}
        } return
          {children}
        }, - ol({ children }) { + ol({ children }: { children?: React.ReactNode }) { return
          {children}
        }, - li({ children, className }) { + li({ children, className }: { children?: React.ReactNode; className?: string }) { if (className?.includes('task-list-item')) { return (
      3. @@ -168,7 +103,7 @@ const MARKDOWN_COMPONENTS: React.ComponentProps['component
      4. ) }, - input({ type, checked }) { + input({ type, checked }: { type?: string; checked?: boolean }) { if (type === 'checkbox') { return } @@ -182,56 +117,31 @@ interface ChatContentProps { onOptionSelect?: (id: string) => void } -function MarkdownChunk({ - content, - animate = false, - trimTop = true, - trimBottom = true, -}: { - content: string - animate?: boolean - trimTop?: boolean - trimBottom?: boolean -}) { - return ( -
        :first-child]:mt-0', - trimBottom && '[&>:last-child]:mb-0', - animate && 'animate-stream-fade-in' - )} - > - - {content} - -
        - ) -} - export function ChatContent({ content, isStreaming = false, onOptionSelect }: ChatContentProps) { const rendered = useStreamingText(content, isStreaming) const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming]) const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text') - const plainText = hasSpecialContent ? '' : rendered - const { committed, incoming, generation } = useStreamingReveal( - plainText, - !hasSpecialContent && isStreaming - ) - - const committedMarkdown = useMemo( - () => (committed ? : null), - [committed, incoming] - ) - if (hasSpecialContent) { return (
        {parsed.segments.map((segment, i) => { if (segment.type === 'text' || segment.type === 'thinking') { - return + return ( +
        :first-child]:mt-0 [&>:last-child]:mb-0')} + > + + {segment.content} + +
        + ) } return ( @@ -243,17 +153,15 @@ export function ChatContent({ content, isStreaming = false, onOptionSelect }: Ch } return ( -
        - {committedMarkdown} - {incoming && ( - - )} +
        :first-child]:mt-0 [&>:last-child]:mb-0')}> + + {rendered} +
        ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx index fd9b6b2cf9..65f002c9cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx @@ -1,8 +1,8 @@ import { memo, useCallback, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' import type { NodeProps } from 'reactflow' import remarkBreaks from 'remark-breaks' -import remarkGfm from 'remark-gfm' +import { Streamdown } from 'streamdown' +import 'streamdown/styles.css' import { cn } from '@/lib/core/utils/cn' import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -303,174 +303,161 @@ function getEmbedInfo(url: string): EmbedInfo | null { /** * Compact markdown renderer for note blocks with tight spacing */ +const NOTE_REMARK_PLUGINS = [remarkBreaks] + +const NOTE_COMPONENTS = { + p: ({ children }: { children?: React.ReactNode }) => ( +

        + {children} +

        + ), + h1: ({ children }: { children?: React.ReactNode }) => ( +

        + {children} +

        + ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

        + {children} +

        + ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

        + {children} +

        + ), + h4: ({ children }: { children?: React.ReactNode }) => ( +

        + {children} +

        + ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
          + {children} +
        + ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
          + {children} +
        + ), + li: ({ children }: { children?: React.ReactNode }) =>
      5. {children}
      6. , + inlineCode: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + code: ({ children, className, ...props }: { children?: React.ReactNode; className?: string }) => ( + + {children} + + ), + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => { + const embedInfo = href ? getEmbedInfo(href) : null + if (embedInfo) { + return ( + + + {children} + + + {embedInfo.type === 'iframe' && ( + +