From ad7f37f0b407ed90663e0ff85dda246f7987b5a9 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Mon, 3 Mar 2025 14:45:03 +0100 Subject: [PATCH] Add lifecycle tests for `MarkdownHooks` Closes GH-894. Reviewed-by: Christian Murphy Reviewed-by: Titus Wormer --- lib/index.js | 3 -- package.json | 2 + test.jsx | 136 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 112 insertions(+), 29 deletions(-) diff --git a/lib/index.js b/lib/index.js index 1e2a4a1..c606747 100644 --- a/lib/index.js +++ b/lib/index.js @@ -206,7 +206,6 @@ export function MarkdownHooks(options) { 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) { @@ -222,10 +221,8 @@ export function MarkdownHooks(options) { ] ) - /* 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) } diff --git a/package.json b/package.json index 1b7cf8b..d0689a3 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ }, "description": "React component to render markdown", "devDependencies": { + "@testing-library/react": "^16.0.0", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -69,6 +70,7 @@ "concat-stream": "^2.0.0", "esbuild": "^0.25.0", "eslint-plugin-react": "^7.0.0", + "global-jsdom": "^26.0.0", "prettier": "^3.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/test.jsx b/test.jsx index b59702f..8bbf4f7 100644 --- a/test.jsx +++ b/test.jsx @@ -1,13 +1,17 @@ /* @jsxRuntime automatic @jsxImportSource react */ /** * @import {Root} from 'hast' - * @import {ComponentProps} from 'react' + * @import {ComponentProps, ReactNode} from 'react' * @import {ExtraProps} from 'react-markdown' + * @import {Plugin} from 'unified' */ import assert from 'node:assert/strict' import test from 'node:test' +import 'global-jsdom/register' +import {render, waitFor} from '@testing-library/react' import concatStream from 'concat-stream' +import {Component} from 'react' import {renderToPipeableStream, renderToStaticMarkup} from 'react-dom/server' import Markdown, {MarkdownAsync, MarkdownHooks} from 'react-markdown' import rehypeRaw from 'rehype-raw' @@ -1106,39 +1110,119 @@ test('MarkdownAsync', async function (t) { // Note: hooks are not supported on the “server”. test('MarkdownHooks', async function (t) { - await t.test('should support `MarkdownHooks` (1)', async function () { - assert.equal(renderToStaticMarkup(), '') - }) + await t.test('should support `MarkdownHooks`', async function () { + const plugin = deferPlugin() - await t.test('should support `MarkdownHooks` (2)', async function () { - return new Promise(function (resolve, reject) { - renderToPipeableStream() - .pipe( - concatStream({encoding: 'u8'}, function (data) { - assert.equal(decoder.decode(data), '') - resolve() - }) - ) - .on('error', reject) + const {container} = render( + + ) + + assert.equal(container.innerHTML, '') + plugin.resolve() + await waitFor(() => { + assert.notEqual(container.innerHTML, '') }) + assert.equal(container.innerHTML, '

a

') }) await t.test( 'should support async plugins w/ `MarkdownHooks` (`rehype-starry-night`)', async function () { - return new Promise(function (resolve) { - renderToPipeableStream( - - ).pipe( - concatStream({encoding: 'u8'}, function (data) { - assert.equal(decoder.decode(data), '') - resolve() - }) - ) + const plugin = deferPlugin() + + const {container} = render( + + ) + + assert.equal(container.innerHTML, '') + plugin.resolve() + await waitFor(() => { + assert.notEqual(container.innerHTML, '') }) + assert.equal( + container.innerHTML, + '
console.log(3.14)\n
' + ) } ) + + await t.test('should support `MarkdownHooks` that error', async function () { + const plugin = deferPlugin() + + const {container} = render( + + + + ) + + assert.equal(container.innerHTML, '') + plugin.reject(new Error('rejected')) + await waitFor(() => { + assert.notEqual(container.innerHTML, '') + }) + assert.equal(container.innerHTML, 'Error: rejected') + }) }) + +/** + * @typedef DeferredPlugin + * @property {Plugin<[]>} plugin + * A unified plugin + * @property {() => void} resolve + * Resolve the plugin. + * @property {(error: Error) => void} reject + * Reject the plugin. + */ + +/** + * Create an async unified plugin which waits until a promise is resolved. + * + * @returns {DeferredPlugin} + * The plugin and resolver. + */ +function deferPlugin() { + /** @type {() => void} */ + let res + /** @type {(error: Error) => void} */ + let rej + /** @type {Promise} */ + const promise = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + + return { + resolve() { + res() + }, + reject(error) { + rej(error) + }, + plugin() { + return () => promise + } + } +} + +class ErrorBoundary extends Component { + state = { + error: null + } + + /** + * @param {Error} error + */ + componentDidCatch(error) { + this.setState({error}) + } + + render() { + const {children} = /** @type {{children: ReactNode}} */ (this.props) + const {error} = this.state + + return error ? String(error) : children + } +}