Skip to content
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

Add lifecycle tests for MarkdownHooks #894

Merged
merged 2 commits into from
Mar 3, 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
3 changes: 0 additions & 3 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@
},
"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",
"c8": "^10.0.0",
"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",
Expand Down
136 changes: 110 additions & 26 deletions test.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(<MarkdownHooks children={'a'} />), '')
})
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(<MarkdownHooks children={'a'} />)
.pipe(
concatStream({encoding: 'u8'}, function (data) {
assert.equal(decoder.decode(data), '')
resolve()
})
)
.on('error', reject)
const {container} = render(
<MarkdownHooks children={'a'} rehypePlugins={[plugin.plugin]} />
)

assert.equal(container.innerHTML, '')
plugin.resolve()
await waitFor(() => {
assert.notEqual(container.innerHTML, '')
})
assert.equal(container.innerHTML, '<p>a</p>')
})

await t.test(
'should support async plugins w/ `MarkdownHooks` (`rehype-starry-night`)',
async function () {
return new Promise(function (resolve) {
renderToPipeableStream(
<MarkdownHooks
children={'```js\nconsole.log(3.14)'}
rehypePlugins={[rehypeStarryNight]}
/>
).pipe(
concatStream({encoding: 'u8'}, function (data) {
assert.equal(decoder.decode(data), '')
resolve()
})
)
const plugin = deferPlugin()

const {container} = render(
<MarkdownHooks
children={'```js\nconsole.log(3.14)'}
rehypePlugins={[plugin.plugin, rehypeStarryNight]}
/>
)

assert.equal(container.innerHTML, '')
plugin.resolve()
await waitFor(() => {
assert.notEqual(container.innerHTML, '')
})
assert.equal(
container.innerHTML,
'<pre><code class="language-js"><span class="pl-en">console</span>.<span class="pl-c1">log</span>(<span class="pl-c1">3.14</span>)\n</code></pre>'
)
}
)

await t.test('should support `MarkdownHooks` that error', async function () {
const plugin = deferPlugin()

const {container} = render(
<ErrorBoundary>
<MarkdownHooks children={'a'} rehypePlugins={[plugin.plugin]} />
</ErrorBoundary>
)

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<void>} */
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
}
}