Skip to content

Commit 2bb3f2b

Browse files
committed
fix: prevent AST and options mutation bugs in all entry points
Prevents text accumulation and unexpected side effects when: - AST nodes are cached and reused with memoization - Options objects are reused across multiple compiler calls Fixes: - AST mutation in solid.tsx post-processing (creates new node instead of mutating) - Options mutation in solid.tsx, react.tsx, and native.tsx (creates shallow copy before mutation) All entry points now safely handle memoization and object reuse.
1 parent 55428cb commit 2bb3f2b

File tree

8 files changed

+258
-45
lines changed

8 files changed

+258
-45
lines changed

.changeset/fix-mutation-bugs.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'markdown-to-jsx': patch
3+
---
4+
5+
Fix AST and options mutation bugs that could cause unexpected side effects when using memoization or reusing objects across multiple compiler calls.

.cursor/rules/commits.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
alwaysApply: true
33
---
44

5-
when writing commit messages on public code, focus on the user-facing change; how does this change benefit them? ensure that noteworthy public changes have a changeset
5+
when writing commit messages on public code, focus on the user-facing change; how does this change benefit them? ensure that noteworthy public changes and bugfixes have a changeset
66

77
when writing commit messages on private/infra code, focus on the technical change; how does this change benefit the codebase? keep it short
88

src/native.spec.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,3 +958,46 @@ describe('footnotes', () => {
958958
}).not.toThrow()
959959
})
960960
})
961+
962+
describe('options immutability', () => {
963+
it('should not mutate options object when calling astToNative multiple times', () => {
964+
// Test that astToNative doesn't mutate the options object when called multiple times
965+
// This is important for memoization - if the same options object is reused,
966+
// mutations could cause unexpected side effects
967+
const markdown = '# Hello world'
968+
const ast = parser(markdown)
969+
const options = { slugify: (input: string) => input.toLowerCase() }
970+
const originalOverrides = options.overrides
971+
972+
// First call
973+
astToNative(ast, options)
974+
975+
// Verify options object wasn't mutated
976+
expect(options.overrides).toBe(originalOverrides)
977+
978+
// Second call with same options
979+
astToNative(ast, options)
980+
981+
// Options should still be unchanged
982+
expect(options.overrides).toBe(originalOverrides)
983+
})
984+
985+
it('should not mutate options object when calling compiler multiple times', () => {
986+
// Test that compiler doesn't mutate the options object when called multiple times
987+
const markdown = '# Hello world'
988+
const options = { slugify: (input: string) => input.toLowerCase() }
989+
const originalOverrides = options.overrides
990+
991+
// First call
992+
compiler(markdown, options)
993+
994+
// Verify options object wasn't mutated
995+
expect(options.overrides).toBe(originalOverrides)
996+
997+
// Second call with same options
998+
compiler(markdown, options)
999+
1000+
// Options should still be unchanged
1001+
expect(options.overrides).toBe(originalOverrides)
1002+
})
1003+
})

src/native.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,7 @@ export function astToNative(
651651
ast: MarkdownToJSX.ASTNode[],
652652
options?: NativeOptions
653653
): React.ReactNode {
654-
const opts: NativeOptions = options || {}
654+
const opts: NativeOptions = { ...(options || {}) }
655655
opts.overrides = opts.overrides || {}
656656

657657
const slug = opts.slugify || util.slugify
@@ -798,23 +798,24 @@ export function compiler(
798798
markdown: string = '',
799799
options: NativeOptions = {}
800800
): React.ReactNode {
801-
options.overrides = options.overrides || {}
801+
const opts = { ...(options || {}) }
802+
opts.overrides = opts.overrides || {}
802803

803-
const slug = options.slugify || util.slugify
804-
const sanitize = options.sanitizer || util.sanitizer
804+
const slug = opts.slugify || util.slugify
805+
const sanitize = opts.sanitizer || util.sanitizer
805806

806807
function compile(input: string): React.ReactNode {
807808
const inline =
808-
options.forceInline ||
809-
(!options.forceBlock && !util.SHOULD_RENDER_AS_BLOCK_R.test(input))
809+
opts.forceInline ||
810+
(!opts.forceBlock && !util.SHOULD_RENDER_AS_BLOCK_R.test(input))
810811
const parseOptions: parse.ParseOptions = {
811812
slugify: i => slug(i, util.slugify),
812813
sanitizer: (value: string, tag: string, attribute: string) =>
813814
sanitize(value, tag as MarkdownToJSX.HTMLTags, attribute),
814-
tagfilter: options.tagfilter !== false,
815-
disableAutoLink: options.disableAutoLink,
816-
disableParsingRawHTML: options.disableParsingRawHTML,
817-
enforceAtxHeadings: options.enforceAtxHeadings,
815+
tagfilter: opts.tagfilter !== false,
816+
disableAutoLink: opts.disableAutoLink,
817+
disableParsingRawHTML: opts.disableParsingRawHTML,
818+
enforceAtxHeadings: opts.enforceAtxHeadings,
818819
}
819820

820821
if (!inline) {
@@ -840,7 +841,7 @@ export function compiler(
840841
)
841842

842843
return astToNative(astNodes, {
843-
...options,
844+
...opts,
844845
forceInline: inline,
845846
})
846847
}
@@ -851,7 +852,7 @@ export function compiler(
851852
}
852853

853854
if (
854-
Object.prototype.toString.call(options.overrides) !== '[object Object]'
855+
Object.prototype.toString.call(opts.overrides) !== '[object Object]'
855856
) {
856857
throw new Error(`markdown-to-jsx: options.overrides (second argument property) must be
857858
undefined or an object literal with shape:

src/react.spec.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { afterEach, expect, it, describe, mock, spyOn } from 'bun:test'
33
import * as React from 'react'
44
import { renderToString } from 'react-dom/server'
55
import theredoc from 'theredoc'
6-
import Markdown, { compiler, RuleType, sanitizer } from './react'
6+
import Markdown, { compiler, astToJSX, parser, RuleType, sanitizer } from './react'
77

88
const root = { innerHTML: '' }
99

@@ -3231,3 +3231,46 @@ tags:
32313231
)
32323232
})
32333233
})
3234+
3235+
describe('options immutability', () => {
3236+
it('should not mutate options object when calling astToJSX multiple times', () => {
3237+
// Test that astToJSX doesn't mutate the options object when called multiple times
3238+
// This is important for memoization - if the same options object is reused,
3239+
// mutations could cause unexpected side effects
3240+
const markdown = '# Hello world'
3241+
const ast = parser(markdown)
3242+
const options = { slugify: (input: string) => input.toLowerCase() }
3243+
const originalOverrides = options.overrides
3244+
3245+
// First call
3246+
astToJSX(ast, options)
3247+
3248+
// Verify options object wasn't mutated
3249+
expect(options.overrides).toBe(originalOverrides)
3250+
3251+
// Second call with same options
3252+
astToJSX(ast, options)
3253+
3254+
// Options should still be unchanged
3255+
expect(options.overrides).toBe(originalOverrides)
3256+
})
3257+
3258+
it('should not mutate options object when calling compiler multiple times', () => {
3259+
// Test that compiler doesn't mutate the options object when called multiple times
3260+
const markdown = '# Hello world'
3261+
const options = { slugify: (input: string) => input.toLowerCase() }
3262+
const originalOverrides = options.overrides
3263+
3264+
// First call
3265+
compiler(markdown, options)
3266+
3267+
// Verify options object wasn't mutated
3268+
expect(options.overrides).toBe(originalOverrides)
3269+
3270+
// Second call with same options
3271+
compiler(markdown, options)
3272+
3273+
// Options should still be unchanged
3274+
expect(options.overrides).toBe(originalOverrides)
3275+
})
3276+
})

src/react.tsx

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -577,15 +577,16 @@ export function astToJSX(
577577
ast: MarkdownToJSX.ASTNode[],
578578
options?: MarkdownToJSX.Options
579579
): React.ReactNode {
580-
options.overrides = options.overrides || {}
580+
const opts = { ...(options || {}) }
581+
opts.overrides = opts.overrides || {}
581582

582-
const slug = options.slugify || util.slugify
583-
const sanitize = options.sanitizer || util.sanitizer
584-
const createElement = options.createElement || React.createElement
583+
const slug = opts.slugify || util.slugify
584+
const sanitize = opts.sanitizer || util.sanitizer
585+
const createElement = opts.createElement || React.createElement
585586

586587
// Recursive compile function for HTML content
587588
const compileHTML = (input: string) =>
588-
compiler(input, { ...options, wrapper: null })
589+
compiler(input, { ...opts, wrapper: null })
589590

590591
// JSX custom pragma
591592
// eslint-disable-next-line no-unused-vars
@@ -598,7 +599,7 @@ export function astToJSX(
598599
},
599600
...children
600601
) {
601-
const overrideProps = get(options.overrides, `${tag}.props`, {})
602+
const overrideProps = get(opts.overrides, `${tag}.props`, {})
602603

603604
// Convert HTML attributes to JSX props and compile any HTML content
604605
const jsxProps = htmlAttrsToJSXProps(props || {})
@@ -618,7 +619,7 @@ export function astToJSX(
618619
}
619620

620621
return createElement(
621-
getTag(tag, options.overrides),
622+
getTag(tag, opts.overrides),
622623
{
623624
...jsxProps,
624625
...overrideProps,
@@ -717,10 +718,10 @@ export function astToJSX(
717718
ast = postProcessedAst
718719

719720
const parseOptions: parse.ParseOptions = {
720-
...options,
721+
...opts,
721722
slugify: i => slug(i, util.slugify),
722723
sanitizer: sanitize,
723-
tagfilter: options.tagfilter !== false,
724+
tagfilter: opts.tagfilter !== false,
724725
}
725726

726727
const refs =
@@ -729,16 +730,16 @@ export function astToJSX(
729730
: {}
730731

731732
const emitter = createRenderer(
732-
options.renderRule,
733+
opts.renderRule,
733734
h,
734735
sanitize,
735736
slug,
736737
refs,
737-
options
738+
opts
738739
)
739740

740741
const arr = emitter(ast, {
741-
inline: options.forceInline,
742+
inline: opts.forceInline,
742743
refs: refs,
743744
}) as React.ReactNode[]
744745

@@ -777,14 +778,14 @@ export function astToJSX(
777778
)
778779
}
779780

780-
if (options.wrapper === null) {
781+
if (opts.wrapper === null) {
781782
return arr
782783
}
783784

784-
const wrapper = options.wrapper || (options.forceInline ? 'span' : 'div')
785+
const wrapper = opts.wrapper || (opts.forceInline ? 'span' : 'div')
785786
let jsx: React.ReactNode
786787

787-
if (arr.length > 1 || options.forceWrapper) {
788+
if (arr.length > 1 || opts.forceWrapper) {
788789
jsx = arr
789790
} else if (arr.length === 1) {
790791
return arr[0]
@@ -794,7 +795,7 @@ export function astToJSX(
794795

795796
return createElement(
796797
wrapper,
797-
{ key: 'outer', ...options.wrapperProps },
798+
{ key: 'outer', ...opts.wrapperProps },
798799
jsx
799800
) as React.JSX.Element
800801
}
@@ -803,20 +804,21 @@ export function compiler(
803804
markdown: string = '',
804805
options: MarkdownToJSX.Options = {}
805806
): React.ReactNode {
806-
options.overrides = options.overrides || {}
807+
const opts = { ...(options || {}) }
808+
opts.overrides = opts.overrides || {}
807809

808-
const slug = options.slugify || util.slugify
809-
const sanitize = options.sanitizer || util.sanitizer
810+
const slug = opts.slugify || util.slugify
811+
const sanitize = opts.sanitizer || util.sanitizer
810812

811813
function compile(input: string): React.ReactNode {
812814
const inline =
813-
options.forceInline ||
814-
(!options.forceBlock && !util.SHOULD_RENDER_AS_BLOCK_R.test(input))
815+
opts.forceInline ||
816+
(!opts.forceBlock && !util.SHOULD_RENDER_AS_BLOCK_R.test(input))
815817
const parseOptions: parse.ParseOptions = {
816-
...options,
818+
...opts,
817819
slugify: i => slug(i, util.slugify),
818820
sanitizer: sanitize,
819-
tagfilter: options.tagfilter !== false,
821+
tagfilter: opts.tagfilter !== false,
820822
}
821823

822824
// First pass: collect all reference definitions
@@ -857,7 +859,7 @@ export function compiler(
857859
}
858860

859861
if (
860-
Object.prototype.toString.call(options.overrides) !== '[object Object]'
862+
Object.prototype.toString.call(opts.overrides) !== '[object Object]'
861863
) {
862864
throw new Error(`markdown-to-jsx: options.overrides (second argument property) must be
863865
undefined or an object literal with shape:

0 commit comments

Comments
 (0)