From 1d04e4a25e9b87e7ab6b2bbd09507352c5b0394e Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Tue, 5 Apr 2022 21:30:12 -0400 Subject: [PATCH 1/3] Add async processing support to support async plugins Resolves #680 Signed-off-by: Michael Irwin --- lib/react-markdown.js | 18 ++++++++++++++++-- package.json | 2 ++ test/test.jsx | 23 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/react-markdown.js b/lib/react-markdown.js index 9291e7f3..6c18853f 100644 --- a/lib/react-markdown.js +++ b/lib/react-markdown.js @@ -13,6 +13,7 @@ * @property {PluggableList} [remarkPlugins=[]] * @property {PluggableList} [rehypePlugins=[]] * @property {import('remark-rehype').Options | undefined} [remarkRehypeOptions={}] + * @property {boolean} [async=false] * * @typedef LayoutOptions * @property {string} [className] @@ -69,9 +70,13 @@ const deprecated = { * React component to render markdown. * * @param {ReactMarkdownOptions} options - * @returns {ReactElement} + * @returns {ReactElement | null} */ export function ReactMarkdown(options) { + const [asyncHastNode, setAsyncHastNode] = React.useState( + /** @type {?Root} */ (null) + ) + for (const key in deprecated) { if (own.call(deprecated, key) && own.call(options, key)) { const deprecation = deprecated[key] @@ -104,7 +109,16 @@ export function ReactMarkdown(options) { ) } - const hastNode = processor.runSync(processor.parse(file), file) + if (options.async && !asyncHastNode) { + processor + .run(processor.parse(file), file) + .then((node) => setAsyncHastNode(node)) + return null + } + + const hastNode = options.async + ? /** @type Root */ (asyncHastNode) + : processor.runSync(processor.parse(file), file) if (hastNode.type !== 'root') { throw new TypeError('Expected a `root` node') diff --git a/package.json b/package.json index 88e4bbc2..9df9be1f 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/react-is": "^17.0.0", + "@types/react-test-renderer": "^17.0.0", "c8": "^7.0.0", "esbuild": "^0.14.0", "eslint-config-xo-react": "^0.27.0", @@ -113,6 +114,7 @@ "prettier": "^2.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-test-renderer": "^18.0.0", "rehype-raw": "^6.0.0", "remark-cli": "^10.0.0", "remark-gfm": "^3.0.0", diff --git a/test/test.jsx b/test/test.jsx index e904b8d1..77c2d64f 100644 --- a/test/test.jsx +++ b/test/test.jsx @@ -6,10 +6,12 @@ * @typedef {import('hast').Text} Text * @typedef {import('react').ReactNode} ReactNode * @typedef {import('../index.js').Components} Components + * @typedef {import('react-test-renderer').ReactTestRenderer} ReactTestRenderer */ import fs from 'node:fs' import path from 'node:path' +import {fail} from 'node:assert' import {test} from 'uvu' import * as assert from 'uvu/assert' import React from 'react' @@ -18,6 +20,7 @@ import {visit} from 'unist-util-visit' import raw from 'rehype-raw' import toc from 'remark-toc' import ReactDom from 'react-dom/server' +import renderer, {act} from 'react-test-renderer' import Markdown from '../index.js' const own = {}.hasOwnProperty @@ -27,6 +30,7 @@ const own = {}.hasOwnProperty * @returns {string} */ function asHtml(input) { + if (!input) return '' return ReactDom.renderToStaticMarkup(input) } @@ -1424,4 +1428,23 @@ test('should crash on a plugin replacing `root`', () => { }, /Expected a `root` node/) }) +test('should work correctly when executed asynchronously', async () => { + const input = '# Test' + + /** @type {ReactTestRenderer | undefined} */ + let component + await act(async () => { + component = renderer.create() + }) + + if (!component) fail('component not set') + + const renderedOutput = component.toJSON() + if (!renderedOutput) fail('No rendered output provided') + if (Array.isArray(renderedOutput)) fail('Not expecting multiple children') + + assert.equal(renderedOutput.type, 'h1') + assert.equal(renderedOutput.children, ['Test']) +}) + test.run() From bc044a7e33dc65953d9a5670ef961d1d007b93c4 Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Wed, 6 Apr 2022 06:14:43 -0400 Subject: [PATCH 2/3] Add async flag to the readme Signed-off-by: Michael Irwin --- readme.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/readme.md b/readme.md index 6223cfc9..7f013a95 100644 --- a/readme.md +++ b/readme.md @@ -211,6 +211,9 @@ The default export is `ReactMarkdown`. * `transformImageUri` (`(src, alt, title) => string`, default: [`uriTransformer`][uri-transformer], optional)\ change URLs on images, pass `null` to allow all URLs, see [security][] +* `async` (`boolean`, default: `false`)\ + change processing to support plugins that run asynchronously. Note that + async usage may break server-side rendering. ### `uriTransformer` From 4e7fb5248260f829202d39dbf80be2a3453e49b5 Mon Sep 17 00:00:00 2001 From: Michael Irwin Date: Wed, 6 Apr 2022 06:34:15 -0400 Subject: [PATCH 3/3] Add an actual async plugin to test case Signed-off-by: Michael Irwin --- test/test.jsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/test.jsx b/test/test.jsx index 77c2d64f..4c77a8e1 100644 --- a/test/test.jsx +++ b/test/test.jsx @@ -1430,11 +1430,21 @@ test('should crash on a plugin replacing `root`', () => { test('should work correctly when executed asynchronously', async () => { const input = '# Test' + let pluginExecuted = false + + /** @type {import('unified').Plugin} */ + const asyncPlugin = () => { + return async () => { + pluginExecuted = true + } + } /** @type {ReactTestRenderer | undefined} */ let component await act(async () => { - component = renderer.create() + component = renderer.create( + + ) }) if (!component) fail('component not set') @@ -1445,6 +1455,7 @@ test('should work correctly when executed asynchronously', async () => { assert.equal(renderedOutput.type, 'h1') assert.equal(renderedOutput.children, ['Test']) + assert.equal(pluginExecuted, true) }) test.run()