From c9c266d2e2cbf7a29b301748357e427b9cffdf46 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:14:55 +0300 Subject: [PATCH 1/6] Fix cached MarkdownHooks tree mutation during rendering --- lib/index.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index 06f07e1..00f0a0f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -248,7 +248,32 @@ export function MarkdownHooks(options) { if (error) throw error - return tree ? post(tree, options) : options.fallback + return tree ? post(copyTree(tree), options) : options.fallback +} + +/** + * Copy the mutable tree structure before processing a cached hook result. + * + * @template {Nodes} Node + * @param {Node} node + * Node to copy. + * @returns {Node} + * Copy with independent children and element properties. + */ +function copyTree(node) { + const copy = {...node} + + if ('children' in copy) { + copy.children = copy.children.map(function (child) { + return copyTree(child) + }) + } + + if (copy.type === 'element') { + copy.properties = {...copy.properties} + } + + return copy } /** From 13576306e6905fd4b0a6299753c89cb6ec5f3db2 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:10:50 +0300 Subject: [PATCH 2/6] Run render transforms in the rehype pipeline --- lib/index.js | 137 +++++++++++++++++++++++++-------------------------- 1 file changed, 67 insertions(+), 70 deletions(-) diff --git a/lib/index.js b/lib/index.js index 00f0a0f..87c1bdc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -216,7 +216,17 @@ export function MarkdownHooks(options) { function () { return createProcessor(options) }, - [options.rehypePlugins, options.remarkPlugins, options.remarkRehypeOptions] + [ + options.allowElement, + options.allowedElements, + options.disallowedElements, + options.rehypePlugins, + options.remarkPlugins, + options.remarkRehypeOptions, + options.skipHtml, + options.unwrapDisallowed, + options.urlTransform + ] ) const [error, setError] = useState( /** @type {Error | undefined} */ (undefined) @@ -248,32 +258,7 @@ export function MarkdownHooks(options) { if (error) throw error - return tree ? post(copyTree(tree), options) : options.fallback -} - -/** - * Copy the mutable tree structure before processing a cached hook result. - * - * @template {Nodes} Node - * @param {Node} node - * Node to copy. - * @returns {Node} - * Copy with independent children and element properties. - */ -function copyTree(node) { - const copy = {...node} - - if ('children' in copy) { - copy.children = copy.children.map(function (child) { - return copyTree(child) - }) - } - - if (copy.type === 'element') { - copy.properties = {...copy.properties} - } - - return copy + return tree ? post(tree, options) : options.fallback } /** @@ -296,6 +281,7 @@ function createProcessor(options) { .use(remarkPlugins) .use(remarkRehype, remarkRehypeOptions) .use(rehypePlugins) + .use(rehypeTransform, options) return processor } @@ -326,60 +312,25 @@ function createFile(options) { } /** - * Process the result from unified some more. + * Add the transforms that must run once for each processed tree. * - * @param {Nodes} tree - * Tree. * @param {Readonly} options * Props. - * @returns {ReactElement} - * React element. + * @returns {(tree: Root) => undefined} + * Transform. */ -function post(tree, options) { +function rehypeTransform(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)) { - unreachable( - 'Unexpected `' + - deprecation.from + - '` prop, ' + - (deprecation.to - ? 'use `' + deprecation.to + '` instead' - : 'remove it') + - ' (see <' + - changelog + - '#' + - deprecation.id + - '> for more info)' - ) - } - } - - if (allowedElements && disallowedElements) { - unreachable( - 'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other' - ) + return function (tree) { + visit(tree, transform) } - visit(tree, transform) - - return toJsxRuntime(tree, { - Fragment, - components, - ignoreInvalidStyle: true, - jsx, - jsxs, - passKeys: true, - passNode: true - }) - /** @type {BuildVisitor} */ function transform(node, index, parent) { if (node.type === 'raw' && parent && typeof index === 'number') { @@ -408,9 +359,7 @@ function post(tree, options) { } } } - } - if (node.type === 'element') { let remove = allowedElements ? !allowedElements.includes(node.tagName) : disallowedElements @@ -434,6 +383,54 @@ function post(tree, options) { } } +/** + * Process the result from unified some more. + * + * @param {Nodes} tree + * Tree. + * @param {Readonly} options + * Props. + * @returns {ReactElement} + * React element. + */ +function post(tree, options) { + const components = options.components + + for (const deprecation of deprecations) { + if (Object.hasOwn(options, deprecation.from)) { + unreachable( + 'Unexpected `' + + deprecation.from + + '` prop, ' + + (deprecation.to + ? 'use `' + deprecation.to + '` instead' + : 'remove it') + + ' (see <' + + changelog + + '#' + + deprecation.id + + '> for more info)' + ) + } + } + + if (allowedElements && disallowedElements) { + unreachable( + 'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other' + ) + } + + return toJsxRuntime(tree, { + Fragment, + components, + ignoreInvalidStyle: true, + jsx, + jsxs, + passKeys: true, + passNode: true + }) +} + /** * Make a URL safe. * From 3aa1e66e9c48fb0ef4e693ac5c8ab56dbae80f75 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:11:07 +0300 Subject: [PATCH 3/6] Test MarkdownHooks transform stability --- test.jsx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test.jsx b/test.jsx index 1b254db..2060942 100644 --- a/test.jsx +++ b/test.jsx @@ -1215,6 +1215,35 @@ test('MarkdownHooks', async function (t) { assert.equal(result.container.innerHTML, '

b

') }) + + await t.test('should not reapply transforms to cached trees', async function () { + function urlTransform(url) { + return '/proxy' + url + } + + const result = render( + + ) + + await waitFor(function () { + assert.equal( + result.container.innerHTML, + '

a

' + ) + }) + + result.rerender( + + ) + + assert.equal(result.container.innerHTML, '

a

') + }) }) /** From a6e6651c6b5b58cacc03c402669c306e0ceea542 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:57:36 +0300 Subject: [PATCH 4/6] fix: retain conflicting filter option validation --- lib/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/index.js b/lib/index.js index 87c1bdc..5ef2fa0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -394,7 +394,9 @@ function rehypeTransform(options) { * React element. */ function post(tree, options) { + const allowedElements = options.allowedElements const components = options.components + const disallowedElements = options.disallowedElements for (const deprecation of deprecations) { if (Object.hasOwn(options, deprecation.from)) { From fa82e4049e305823ec04d91d5111660ac51c03fb Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:02:12 +0300 Subject: [PATCH 5/6] style: format MarkdownHooks regression test --- test.jsx | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/test.jsx b/test.jsx index 2060942..1735c33 100644 --- a/test.jsx +++ b/test.jsx @@ -1216,34 +1216,34 @@ test('MarkdownHooks', async function (t) { assert.equal(result.container.innerHTML, '

b

') }) - await t.test('should not reapply transforms to cached trees', async function () { - function urlTransform(url) { - return '/proxy' + url - } + await t.test( + 'should not reapply transforms to cached trees', + async function () { + function urlTransform(url) { + return '/proxy' + url + } - const result = render( - - ) + const result = render( + + ) + + await waitFor(function () { + assert.equal( + result.container.innerHTML, + '

a

' + ) + }) + + result.rerender( + + ) - await waitFor(function () { assert.equal( result.container.innerHTML, '

a

' ) - }) - - result.rerender( - - ) - - assert.equal(result.container.innerHTML, '

a

') - }) + } + ) }) /** From 36cbbac704eed587fb98c718f03c21444da1a0c8 Mon Sep 17 00:00:00 2001 From: Oskar Eichler <62393985+OskarEichler@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:16:38 +0300 Subject: [PATCH 6/6] test: type the URL transform fixture --- test.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test.jsx b/test.jsx index 1735c33..1128b0a 100644 --- a/test.jsx +++ b/test.jsx @@ -1219,6 +1219,10 @@ test('MarkdownHooks', async function (t) { await t.test( 'should not reapply transforms to cached trees', async function () { + /** + * @param {string} url + * @returns {string} + */ function urlTransform(url) { return '/proxy' + url }