Skip to content

Add support for async plugins #890

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

Merged
merged 6 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@
* @typedef {import('./lib/index.js').UrlTransform} UrlTransform
*/

export {Markdown as default, defaultUrlTransform} from './lib/index.js'
export {
MarkdownAsync,
MarkdownHooks,
Markdown as default,
defaultUrlTransform
} from './lib/index.js'
154 changes: 130 additions & 24 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* @import {Element, ElementContent, Nodes, Parents, Root} from 'hast'
* @import {Root as MdastRoot} from 'mdast'
* @import {ComponentProps, ElementType, ReactElement} from 'react'
* @import {Options as RemarkRehypeOptions} from 'remark-rehype'
* @import {BuildVisitor} from 'unist-util-visit'
* @import {PluggableList} from 'unified'
* @import {PluggableList, Processor} from 'unified'
*/

/**
Expand Down Expand Up @@ -95,6 +96,7 @@ import {unreachable} from 'devlop'
import {toJsxRuntime} from 'hast-util-to-jsx-runtime'
import {urlAttributes} from 'html-url-attributes'
import {Fragment, jsx, jsxs} from 'react/jsx-runtime'
import {createElement, useEffect, useState} from 'react'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'
Expand Down Expand Up @@ -149,33 +151,119 @@ const deprecations = [
/**
* Component to render markdown.
*
* This is a synchronous component.
* When using async plugins,
* see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}.
*
* @param {Readonly<Options>} options
* Props.
* @returns {ReactElement}
* React element.
*/
export function Markdown(options) {
const allowedElements = options.allowedElements
const allowElement = options.allowElement
const children = options.children || ''
const className = options.className
const components = options.components
const disallowedElements = options.disallowedElements
const processor = createProcessor(options)
const file = createFile(options)
return post(processor.runSync(processor.parse(file), file), options)
}

/**
* Component to render markdown with support for async plugins
* through async/await.
*
* Components returning promises are supported on the server.
* For async support on the client,
* see {@linkcode MarkdownHooks}.
*
* @param {Readonly<Options>} options
* Props.
* @returns {Promise<ReactElement>}
* Promise to a React element.
*/
export async function MarkdownAsync(options) {
const processor = createProcessor(options)
const file = createFile(options)
const tree = await processor.run(processor.parse(file), file)
return post(tree, options)
}

/**
* Component to render markdown with support for async plugins through hooks.
*
* This uses `useEffect` and `useState` hooks.
* Hooks run on the client and do not immediately render something.
* For async support on the server,
* see {@linkcode MarkdownAsync}.
*
* @param {Readonly<Options>} options
* Props.
* @returns {ReactElement}
* React element.
*/
export function MarkdownHooks(options) {
const processor = createProcessor(options)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The processor is only needed inside the useEffect. We can move it there as well. That means the processor isn’t created on every render and doesn’t need to be in the useEffect dependency array.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn’t Object.is used for the dependencies? And because Object.is({}, {}) // false, that would mean putting a created processor in dependencies would never be equal?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React also allows you to omit the array. That is what I did now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could also improve the situation by better teaching users. For example, this is not how someone would typically use this library:

import React from 'react'
import {createRoot} from 'react-dom/client'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'

const markdown = `Just a link: www.nasa.gov.`

createRoot(document.body).render(
  <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
)

A more realistic example is this:

import React from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'

const markdown = `Just a link: www.nasa.gov.`

function App() {
  return (
    <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
  )
}

This is problematic. The remarkPlugins changes for every rerender of <App /> The solution is this to move non-primitive props outside of the React component:

import React from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'

const markdown = `Just a link: www.nasa.gov.`
const remarkPlugins = [remarkGfm]

function App() {
  return (
    <Markdown remarkPlugins={remarkPlugins}>{markdown}</Markdown>
  )
}

This was always a problem, but it’s worse when dealing with asynchronous state.

const [error, setError] = useState(
/** @type {Error | undefined} */ (undefined)
)
const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined))

useEffect(
/* c8 ignore next 7 -- hooks are client-only. */
function () {
const file = createFile(options)
processor.run(processor.parse(file), file, function (error, tree) {
setError(error)
setTree(tree)
})
},
[
options.children,
options.rehypePlugins,
options.remarkPlugins,
options.remarkRehypeOptions
]
)

/* c8 ignore next -- hooks are client-only. */
if (error) throw error

/* c8 ignore next -- hooks are client-only. */
return tree ? post(tree, options) : createElement(Fragment)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don’t have to return an empty fragment here. It’s sufficient to return undefined.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would make the types for only this function different from the other ones

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correct return type for a React component is ReactNode. According to TypeScript 5.0 and before this was ReactElement, which was wrong. It is not wrong for a React component to return ReactElement, string, number, boolean, null, or undefined, but it’s an implementation detail we shouldn’t worry about. TypeScript 5.0 is almost no longer supported by the DefinitelyTyped support window. That’s a pretty good indication we don’t need to support it anymore, especially for new APIs.

This has gotten more relevant with your latest changes, as we now no longer use use. This means we also don’t integrate with <Suspense/>. I think we should add a new prop loader of type ReactNode that’s returned while the processor is still processing.

}

/**
* Set up the `unified` processor.
*
* @param {Readonly<Options>} options
* Props.
* @returns {Processor<MdastRoot, MdastRoot, Root, undefined, undefined>}
* Result.
*/
function createProcessor(options) {
const rehypePlugins = options.rehypePlugins || emptyPlugins
const remarkPlugins = options.remarkPlugins || emptyPlugins
const remarkRehypeOptions = options.remarkRehypeOptions
? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
: emptyRemarkRehypeOptions
const skipHtml = options.skipHtml
const unwrapDisallowed = options.unwrapDisallowed
const urlTransform = options.urlTransform || defaultUrlTransform

const processor = unified()
.use(remarkParse)
.use(remarkPlugins)
.use(remarkRehype, remarkRehypeOptions)
.use(rehypePlugins)

return processor
}

/**
* Set up the virtual file.
*
* @param {Readonly<Options>} options
* Props.
* @returns {VFile}
* Result.
*/
function createFile(options) {
const children = options.children || ''
const file = new VFile()

if (typeof children === 'string') {
Expand All @@ -188,11 +276,27 @@ export function Markdown(options) {
)
}

if (allowedElements && disallowedElements) {
unreachable(
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
)
}
return file
}

/**
* Process the result from unified some more.
*
* @param {Nodes} tree
* Tree.
* @param {Readonly<Options>} options
* Props.
* @returns {ReactElement}
* React element.
*/
function post(tree, options) {
const allowedElements = options.allowedElements
const allowElement = options.allowElement
const components = options.components
const disallowedElements = options.disallowedElements
const skipHtml = options.skipHtml
const unwrapDisallowed = options.unwrapDisallowed
const urlTransform = options.urlTransform || defaultUrlTransform

for (const deprecation of deprecations) {
if (Object.hasOwn(options, deprecation.from)) {
Expand All @@ -212,26 +316,28 @@ export function Markdown(options) {
}
}

const mdastTree = processor.parse(file)
/** @type {Nodes} */
let hastTree = processor.runSync(mdastTree, file)
if (allowedElements && disallowedElements) {
unreachable(
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
)
}

// Wrap in `div` if there’s a class name.
if (className) {
hastTree = {
if (options.className) {
tree = {
type: 'element',
tagName: 'div',
properties: {className},
properties: {className: options.className},
// Assume no doctypes.
children: /** @type {Array<ElementContent>} */ (
hastTree.type === 'root' ? hastTree.children : [hastTree]
tree.type === 'root' ? tree.children : [tree]
)
}
}

visit(hastTree, transform)
visit(tree, transform)

return toJsxRuntime(hastTree, {
return toJsxRuntime(tree, {
Fragment,
// @ts-expect-error
// React components are allowed to return numbers,
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
],
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
Expand All @@ -65,12 +66,14 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"c8": "^10.0.0",
"concat-stream": "^2.0.0",
"esbuild": "^0.25.0",
"eslint-plugin-react": "^7.0.0",
"prettier": "^3.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rehype-raw": "^7.0.0",
"rehype-starry-night": "^2.0.0",
"remark-cli": "^12.0.0",
"remark-gfm": "^4.0.0",
"remark-preset-wooorm": "^11.0.0",
Expand Down
52 changes: 51 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ React component to render markdown.
* [Use](#use)
* [API](#api)
* [`Markdown`](#markdown)
* [`MarkdownAsync`](#markdownasync)
* [`MarkdownHooks`](#markdownhooks)
* [`defaultUrlTransform(url)`](#defaulturltransformurl)
* [`AllowElement`](#allowelement)
* [`Components`](#components)
Expand Down Expand Up @@ -166,14 +168,58 @@ createRoot(document.body).render(

## API

This package exports the following identifier:
This package exports the identifiers
[`MarkdownAsync`][api-markdown-async],
[`MarkdownHooks`][api-markdown-hooks],
and
[`defaultUrlTransform`][api-default-url-transform].
The default export is [`Markdown`][api-markdown].

### `Markdown`

Component to render markdown.

This is a synchronous component.
When using async plugins,
see [`MarkdownAsync`][api-markdown-async] or
[`MarkdownHooks`][api-markdown-hooks].

###### Parameters

* `options` ([`Options`][api-options])
— props

###### Returns

React element (`JSX.Element`).

### `MarkdownAsync`

Component to render markdown with support for async plugins
through async/await.

Components returning promises are supported on the server.
For async support on the client,
see [`MarkdownHooks`][api-markdown-hooks].

###### Parameters

* `options` ([`Options`][api-options])
— props

###### Returns

Promise to a React element (`Promise<JSX.Element>`).

### `MarkdownHooks`

Component to render markdown with support for async plugins through hooks.

This uses `useEffect` and `useState` hooks.
Hooks run on the client and do not immediately render something.
For async support on the server,
see [`MarkdownAsync`][api-markdown-async].

###### Parameters

* `options` ([`Options`][api-options])
Expand Down Expand Up @@ -779,6 +825,10 @@ abide by its terms.

[api-markdown]: #markdown

[api-markdown-async]: #markdownasync

[api-markdown-hooks]: #markdownhooks

[api-options]: #options

[api-url-transform]: #urltransform
Expand Down
Loading