-
-
Notifications
You must be signed in to change notification settings - Fork 917
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
Changes from 1 commit
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 |
---|---|---|
@@ -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' | ||
*/ | ||
|
||
/** | ||
|
@@ -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, use, useEffect, useState} from 'react' | ||
import remarkParse from 'remark-parse' | ||
import remarkRehype from 'remark-rehype' | ||
import {unified} from 'unified' | ||
|
@@ -108,6 +110,7 @@ const changelog = | |
const emptyPlugins = [] | ||
/** @type {Readonly<RemarkRehypeOptions>} */ | ||
const emptyRemarkRehypeOptions = {allowDangerousHtml: true} | ||
const resolved = /** @type {Promise<undefined>} */ (Promise.resolve()) | ||
const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i | ||
|
||
// Mutable because we `delete` any time it’s used and a message is sent. | ||
|
@@ -149,33 +152,108 @@ 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 is 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. | ||
* | ||
* Hooks run on the client. | ||
* 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) | ||
const [promise, setPromise] = useState( | ||
/** @type {Promise<Root | undefined>} */ (resolved) | ||
) | ||
|
||
useEffect( | ||
/* c8 ignore next 4 -- hooks are client-only. */ | ||
function () { | ||
const file = createFile(options) | ||
setPromise(processor.run(processor.parse(file), file)) | ||
}, | ||
[options.children] | ||
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. The dependency should specify all component local values needed to run the effect. 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. you want an array of every field in options? 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. that’s why 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. I don’t know what you mean by “component local values” — can you use (pseudo)code to explain your ideas? 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. Currently The real fix is to move the creation of Best would be to enable 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. So you are suggesting to add every field on 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. React also allows you to omit the array. That is what I did now. Now there are no bugs. 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. That means it will re-run after every render. So this is equivalent to adding every field of the options on the array, including irrelevant ones such as We only need the options used inside the effect as dependencies. That means moving the processor inside and adding all options used by I still believe React’s dependency array implementation doesn’t match well with the fact that we allow objects as props. This is solved by #891, but needs fine-tuning and testing. We could even leverage this to improve performance for the synchronous API. 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. #891 is the same; it recalculates too. It seems troubling that React only allowed primitives. That sounds like a problem that react should solve—just like lacking async support. I welcome you spending time in the future to create a good but complex diffing strategy that improves performance for all APIs. But I do not think that this issue needs to wait for that. 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. Ok let’s go with this for now, but at least add the processor inside the effect and add the appropriate dependency array. We should also handle cancellation. useEffect(
() => {
let cancelled = false
const processor = createProcessor(options)
const file = createFile(options)
processor.run(processor.parse(file), file, function (error, tree) {
if (!cancelled) {
setError(error)
setTree(tree)
}
})
return () => {
cancelled = true
}
},
[options.children, options.remarkRehypeOptions, options.remarkPlugins, options.rehypePlugins]
) We also need to think about how to handle loading states. The current implementation shows the loading state until something is rendered. It then only updates the state when the processor was done running. The previous implementation with Maybe we should add a prop for this to let the user decide: useEffect(
() => {
if (options.reset) {
setError(null)
setTree(null)
}
let cancelled = false
const processor = createProcessor(options)
const file = createFile(options)
processor.run(processor.parse(file), file, function (error, tree) {
if (!cancelled) {
setError(error)
setTree(tree)
}
})
return () => {
cancelled = true
}
},
[options.children, options.remarkRehypeOptions, options.remarkPlugins, options.reset, options.rehypePlugins]
) |
||
) | ||
|
||
const tree = use(promise) | ||
|
||
/* c8 ignore next -- hooks are client-only. */ | ||
return tree ? post(tree, options) : createElement(Fragment) | ||
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. You don’t have to return an empty fragment here. It’s sufficient to return undefined. 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. That would make the types for only this function different from the other ones 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. The correct return type for a React component is This has gotten more relevant with your latest changes, as we now no longer use |
||
} | ||
|
||
/** | ||
* 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') { | ||
|
@@ -188,11 +266,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)) { | ||
|
@@ -212,26 +306,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, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
|
@@ -166,14 +168,57 @@ 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 is 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. | ||
|
||
Hooks run on the client. | ||
For async support on the server, | ||
see [`MarkdownAsync`][api-markdown-async]. | ||
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. Could we expand on this a bit? Including that this uses |
||
|
||
###### Parameters | ||
|
||
* `options` ([`Options`][api-options]) | ||
|
@@ -779,6 +824,10 @@ abide by its terms. | |
|
||
[api-markdown]: #markdown | ||
|
||
[api-markdown-async]: #markdownasync | ||
|
||
[api-markdown-hooks]: #markdownhooks | ||
|
||
[api-options]: #options | ||
|
||
[api-url-transform]: #urltransform | ||
|
There was a problem hiding this comment.
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 theuseEffect
dependency array.There was a problem hiding this comment.
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 becauseObject.is({}, {}) // false
, that would mean putting a createdprocessor
in dependencies would never be equal?There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
A more realistic example is this:
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:This was always a problem, but it’s worse when dealing with asynchronous state.