remarkjs / react-markdown

Markdown component for React
https://remarkjs.github.io/react-markdown/
MIT License
13.26k stars 876 forks source link

Is it possible to prevent remounting of components (<ReactMarkdown components={...}>) when markdown changes? #849

Closed KevinWang15 closed 3 months ago

KevinWang15 commented 3 months ago

Initial checklist

Problem

When using ReactMarkdown with custom components (particularly for code blocks), any change to the markdown content causes the component to remount. This leads to issues such as loss of user selections and interactions within these components.

For example

import React, {useEffect, useState} from 'react';
import ReactMarkdown from 'react-markdown';
import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter';
import {darcula} from 'react-syntax-highlighter/dist/esm/styles/prism';

const StreamingCodeBlock = () => {
    const [markdown, setMarkdown] = useState('```javascript\n// Initial code\n');

    useEffect(() => {
        const interval = setInterval(() => {
            setMarkdown(prev => prev + `console.log('New line ${Date.now()}');\n`);
        }, 500);

        return () => clearInterval(interval);
    }, []);

    return (
        <ReactMarkdown
            components={{
                code({className, children, ...props}) {
                    const match = /language-(\w+)/.exec(className || '');
                    return match ? (
                        <SyntaxHighlighter
                            language={match[1]}
                            {...props}
                            style={darcula}
                        >
                            {String(children).replace(/\n$/, '')}
                        </SyntaxHighlighter>
                    ) : (
                        <code className={className} {...props}>
                            {children}
                        </code>
                    );
                },
            }}
        >
            {markdown}
        </ReactMarkdown>
    );
};

If you try to select some code, each time the markdown is updated, the <SyntaxHighlighter> gets remounted, and user selection is lost.

https://github.com/user-attachments/assets/d216dc0d-cd30-4ef3-8217-e71a1cdf9c1b


The use case is with LLM chatbots.

Markdown content is often generated and displayed in a streaming fashion with LLM chatbots.

The user sees the beginning of the code and starts interacting with it (e.g., selecting parts of the code, placing the cursor for copying).

But with the current implementation, as the AI continues to generate more code, appending to the existing block, the ReactMarkdown component is remounted and the user selection is lost, leading to a frustrating experience for users who are trying to interact with the code while it's still being generated.

Solution

I don't have a solution - I'm not familiar with the internals of ReactMarkdown.

But I think this should be possible.

Alternatives

I tried but couldn't find any workaround. Updating code in ReactMarkdown to make it possible is the only solution.

KevinWang15 commented 3 months ago

If we could just let the user specify a <Code> component for code instead of having to write a function, and update the props of <Code> instead of remounting it, this problem could be solved.

I checked many LLM chatbot frontends, they all have this problem. If this could get fixed then it will improve the quality of life for many developers!

KevinWang15 commented 3 months ago

hmmm I tried again, is it as simple as this?

<ReactMarkdown
    components={{
        code: SyntaxHighlighter
    }}
>
    {markdown}
</ReactMarkdown>

If so then it is already possible?.. Let me confirm..

wooorm commented 3 months ago

Sure. But it has nothing to do with this project. Just with react.

You pass new components (new functions) each time. Don‘t.

github-actions[bot] commented 3 months ago

Hi! This was closed. Team: If this was fixed, please add phase/solved. Otherwise, please add one of the no/* labels.

KevinWang15 commented 3 months ago

Thanks a lot @wooorm , it works..

import React, {useEffect, useState} from 'react';
import ReactMarkdown from 'react-markdown';
import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter';
import {darcula} from 'react-syntax-highlighter/dist/esm/styles/prism';

const Code = ({className, children, ...props}) => {
    const match = /language-(\w+)/.exec(className || '');
    return match ? (
        <SyntaxHighlighter
            language={match[1]}
            {...props}
            style={darcula}
        >
            {String(children).replace(/\n$/, '')}
        </SyntaxHighlighter>
    ) : (
        <code className={className} {...props}>
            {children}
        </code>
    );
};

const StreamingCodeBlock = () => {
    const [markdown, setMarkdown] = useState('```javascript\n// Initial code\n');

    useEffect(() => {
        const interval = setInterval(() => {
            setMarkdown(prev => prev + `console.log('New line ${Date.now()}');\n`);
        }, 500);
        return () => clearInterval(interval);
    }, []);

    return (
        <ReactMarkdown components={{code: Code}}>
            {markdown}
        </ReactMarkdown>
    );
};

But again I see a lot of Chatbot UIs doing it wrong, ~like lobe-chat (https://github.com/lobehub/lobe-ui/blob/5a94c8c61443c01400711dc2c956649f1db68765/src/Markdown/index.tsx#L71) cc maintainer of lobe chat here @canisminor1990.~ (update: lobe-chat correctly added useMemo so I think it should produce the correct result, but I just tested lobe-chat and it has the same issue as the one in the above video)

Maybe we could put a small hint in the documentation 😁. Using

    components={{
        code: SyntaxHighlighter // SyntaxHighlighter is a react component
    }}

is more efficient and more correct in many cases than a callback function (which was in the docs).

wooorm commented 3 months ago

I dunno, I feel like developers need to know some react themselves, how to memo-ize things, speed components up, be efficient. The examples also work with document.body and with literal strings of markdown, which you probably don’t want.

NevilleQingNY commented 1 day ago

I believe the issue is with the code property in your components. On each render, a new function is created and assigned to code, causing React to think the component has changed and trigger a re-render. I've tested that wrapping this function with useCallback solves the problem:

const CodeComponent = useCallback(({ className, children, inline }) => {  
  if (inline) {  
    return <code>{children}</code>;  
  }  

  const match = /language-(\w+)/.exec(className || '');  
  const language = (match && match[1]) || '';  

  return (  
    <SyntaxHighlighter language={language}>  
      {String(children)}  
    </SyntaxHighlighter>  
  );  
}, []); // Empty dependency array  

<ReactMarkdown components={{ code: CodeComponent }}>  
  {markdown}  
</ReactMarkdown>  

This ensures the code property always references the same function, preventing unnecessary re-renders.

remcohaszing commented 1 day ago

Yes, define the component outside of the component that renders <ReactMarkdown />