remarkjs / react-remark

React component and hook to use remark to render markdown
https://remarkjs.github.io/react-remark
MIT License
208 stars 7 forks source link

Server rendering/setting initial markdown state #16

Closed mattywong closed 3 years ago

mattywong commented 3 years ago

Subject of the feature

Server rendering react-remark components by passing through an initial state value to useRemark, or using the component's children.

Describe your issue here.

Problem

The useRemark hook's reactContent initial state is null. It looks like the only way to update this state is by using the exposed setMarkdownSource method.

The component sets the state of reactContent in a useEffect calling setMarkdownSource(children) which does not get executed on the server (by calling react-dom/server renderToString).

Alternatives

What are the alternative solutions? Please describe what else you have considered?

Have tried using the first example in README.md, which creates an infinite loop.

Looking through the source code, potential solutions:

setMarkdownSource uses unified.process which is async. We could create a synchronous function which uses unified.processSync to set the initial state based off a new prop in the useRemark hook e.g initialContent, though as I understand, if a provided remark plugin is async, this will throw an error.

I haven't tested this in a server rendered environment, however running this projects storybook seems to work ok with the following.

export const useRemark = ({
  ...,
  initialContent,
}: UseRemarkOptions = {}): [ReactElement | null, (source: string) => void] => {
  const initialParser = useCallback((source: string) => {
    return unified()
      .use(remarkParse, remarkParseOptions)
      .use(remarkPlugins)
      .use(remarkToRehype, remarkToRehypeOptions)
      .use(rehypePlugins)
      .use(rehypeReact, { createElement, Fragment, ...rehypeReactOptions })
      .processSync(source).result as ReactElement;
  }, []);

  const [reactContent, setReactContent] = useState<ReactElement | null>(
    () => initialContent ? initialParser(initialContent) : null
  );

  const setMarkdownSource = useCallback((source: string) => {
    unified()
      .use(remarkParse, remarkParseOptions)
      .use(remarkPlugins)
      .use(remarkToRehype, remarkToRehypeOptions)
      .use(rehypePlugins)
      .use(rehypeReact, { createElement, Fragment, ...rehypeReactOptions })
      .process(source)
      .then((vfile) => setReactContent(vfile.result as ReactElement))
      .catch(onError);
  }, []);

  return [reactContent, setMarkdownSource];
};

export const Remark: FunctionComponent<RemarkProps> = ({
  children,
  ...useRemarkOptions
}: RemarkProps) => {
  const [reactContent, setMarkdownSource] = useRemark({
    ...useRemarkOptions,
    initialContent: children,
  });

  useEffect(() => {
    setMarkdownSource(children);
  }, [children, setMarkdownSource]);

  return reactContent;
};

For now, using react-markdown works fine (which seems to be synchronous) for our use case, however this project seems to align closer to our preference for parsing architecture).

Being able to server render initial content would be a useful feature.

ChristianMurphy commented 3 years ago

Server rendering react-remark components by passing through an initial state value to useRemark

What are you using for serverside rendering? A framework like Gatsby or Next? Something else?

Have tried using the first example in README.md, which creates an infinite loop

Could you share a runnable example of this? for example in https://codesandbox.io

setMarkdownSource uses unified.process which is async

Could you expand why this is an problem? Next, for example, supports async processes in SSR via getStaticProps https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation Another consideration here, remark and rehype plugins can be async, and async plugins can only be used with process, I'd like to avoid the usage of processSync if possible to avoid restrictions on what plugins work.

to set the initial state based off a new prop in the useRemark hook e.g initialContent

an initialContent prop could be a good add.

For now, using react-markdown works fine (which seems to be synchronous) for our use case

FYI, react-markdown v6 will likely be built on top of react-remark.

mattywong commented 3 years ago

Server rendering react-remark components by passing through an initial state value to useRemark

What are you using for serverside rendering? A framework like Gatsby or Next? Something else?

We are server rendering in .NET Core via ReactJS.NET

Have tried using the first example in README.md, which creates an infinite loop

Could you share a runnable example of this? for example in https://codesandbox.io

Below example that crashes:

https://codesandbox.io/s/objective-montalcini-cixpt?

setMarkdownSource uses unified.process which is async

Could you expand why this is an problem? Next, for example, supports async processes in SSR via getStaticProps https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation Another consideration here, remark and rehype plugins can be async, and async plugins can only be used with process, I'd like to avoid the usage of processSync if possible to avoid restrictions on what plugins work.

We are using base react in a polyfilled Chakra core runtime on the server. AFAIK there is no way in base react to set an initial state via async in useState constructor (calling setState in a useEffect is not an option as that does not get run in react-dom/server's renderToString method). Perhaps a solution is to have an option to use processSync with process as the default? e.g a synchronous or async boolean option passed to useRemark hook? However we would still need a way to set the initial content state so react-dom/server can render it's contents.

ChristianMurphy commented 3 years ago

https://codesandbox.io/s/objective-montalcini-cixpt this is the equivalent of:

function InfiniteLoop () {
  const [state, setState] = useState();
  setState({});
  return null;
}

each render setState is called, setState triggers a render, repeat ad infinitum.

a useEffect can be used to call set just once https://codesandbox.io/s/stoic-snow-4846x

We are server rendering in .NET Core via ReactJS.NET in a polyfilled Chakra core runtime on the server

Oof, isomorphic react on a non-V8 runtime, with lightly supported language bindings, that doesn't sound fun, sorry.

Perhaps a solution is to have an option to use processSync with process as the default? e.g a synchronous or async boolean option passed to useRemark hook?

It is an option :thinking: And maybe one that could make sense.

Taking a step back from the problem, from a moment, it sounds like you're looking more for a pure static content generator? Would going from markdown to javascript, and saving/running an ahead of time generated component work in your use case? Something along the lines of: https://mdxjs.com/advanced/api (built on remark)

mattywong commented 3 years ago

Taking a step back from the problem, from a moment, it sounds like you're looking more for a pure static content generator? Would going from markdown to javascript, and saving/running an ahead of time generated component work in your use case? Something along the lines of: https://mdxjs.com/advanced/api (built on remark)

Unfortunately not as the content is stored in a headless CMS. Currently it handles blog posts in markdown, but we are in the process of extending it out to handle custom pages as well (which may contain JSX and/or HTML), which will be handled by a content team and occasional developer for more complicated markup/layouts.

I'm also not sure if you'd be able to server render on nextjs either, though you're able to set initial component props in getStaticProps, this still doesn't give a pathway to setting the initial useRemark inner reactContent state (which is null useState(null) https://github.com/remarkjs/react-remark/blob/main/src/index.ts#L33). Though I haven't tested this in nextjs, my thinking is that it will still render null from the server, then after client hydration, the useEffect will run replacing with the parsed result from unified. Though you could use unified directly in getStaticProps to get it server rendered, however this sort of defeats the purpose of using this package.

EDIT: I have got an example with nextjs as described above on codesandbox (open the result in a new browser window, disable JavaScript and the text will not be rendered): https://codesandbox.io/s/0-no-persistent-layout-elements-forked-yh5pz?file=/pages/index.js

Result window: https://yh5pz.sse.codesandbox.io/

ChristianMurphy commented 3 years ago

I'm also not sure if you'd be able to server render on nextjs either

Not currently, I'm waiting for React Server Components and Serverside Suspense to shake out some more to allow for more flexible async components on the serverside.

Unfortunately not as the content is stored in a headless CMS. Currently it handles blog posts in markdown, but we are in the process of extending it out to handle custom pages as well (which may contain JSX and/or HTML), which will be handled by a content team and occasional developer for more complicated markup/layouts.

What would you think of

export interface UseRemarkOptions {
  remarkParseOptions?: Partial<RemarkParseOptions>;
  remarkToRehypeOptions?: RemarkRehypeOptions;
  rehypeReactOptions?: Partial<RehypeReactOptions<typeof createElement>>;
  remarkPlugins?: PluggableList;
  rehypePlugins?: PluggableList;
}

export const useRemarkSync = (
  source: string,
  {
    remarkParseOptions,
    remarkToRehypeOptions,
    rehypeReactOptions,
    remarkPlugins = [],
    rehypePlugins = [],
  }: UseRemarkOptions = {}
): ReactElement =>
  unified()
    .use(remarkParse, remarkParseOptions)
    .use(remarkPlugins)
    .use(remarkToRehype, remarkToRehypeOptions)
    .use(rehypePlugins)
    .use(rehypeReact, { createElement, Fragment, ...rehypeReactOptions })
    .processSync(source).result as ReactElement;

?

mattywong commented 3 years ago

Looks good to me! Would it make sense to wrap it in a useMemo and have source as the dependency? Looks like it could potentially get expensive and block the main thread if the containing parent component gets re-rendered

tremby commented 3 years ago

Popping my head in just to say that this is a blocker for me. I'm using Next.

pedrosimao commented 3 years ago

I just had same issue with SSG on Next.JS. Using the <Remark> component would result in texts creating a javascript code instead of HTML tags on source code, which is bad for SEO. So I ended up creating my own <Markdown> component with the code snippet from remark-react:

unified().use(parse).use(remark2react).processSync(children).result}
ChristianMurphy commented 3 years ago

added in https://github.com/remarkjs/react-remark/pull/18 documentation at https://github.com/remarkjs/react-remark#server-side-rendering

This change will be part of the next release

jstejada commented 2 years ago

has this been released yet?

ChristianMurphy commented 2 years ago

It is ready for release, but hasn't been yet. /cc @remarkjs/releasers

wooorm commented 2 years ago

@ChristianMurphy your last comment said “This change will be part of the next release”. Sounds like there is more work to do?

ChristianMurphy commented 2 years ago

Sounds like there is more work to do?

There is, cutting a minor release.

wooorm commented 2 years ago

Released.