From 789ae712aa0df63fbfbd82272976ac8b167c6dcd Mon Sep 17 00:00:00 2001 From: Daniels Lee Date: Tue, 25 Nov 2025 22:34:04 -0800 Subject: [PATCH] fix(toc): prevent misnamed mdx components from breaking TOC nesting Discovered through [this ticket](https://linear.app/readme-io/issue/CX-2543/tabapay-toc-does-not-respect-headers-nesting) that the TOC generated by the MDX renderer would become broken with miscalculated nesting depths. This happened when a jsx element was embedded inside the MDX body and the custom component name did not match the exported jsx elements. For example: image here I'm not entirely sure whether this is an edge-case we should be guarding against from our custom component editor. Like, adding validation to ensure that the component name matches what's being exported from it. My gut tells me it'd be really hard to enforce that. So, the fix here is to simply take this edge-case into account and add protections for the cases where this happens. When a component name doesn't match the jsx element name, it previously was making its way into the `plugin/toc` flow. But b/c it has no `tagName`, the `getDepth()` calculation would break and return a `NaN` instead of a valid integer. This would break the nesting logic in `toctoHast()` and cause all headers to render at the first level. Fixed this by: 1. filtering away these mismatched components that make it through 1. additionally updating the `getDepth()` function to be more fail-proof and handle the case where `tagName` is undefined CX-2543 --- __tests__/plugins/toc.test.tsx | 36 ++++++++++++++++++++++++++++++++++ processor/plugin/toc.ts | 26 +++++++++++++++++++++--- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/__tests__/plugins/toc.test.tsx b/__tests__/plugins/toc.test.tsx index a6367bd29..1b044abe6 100644 --- a/__tests__/plugins/toc.test.tsx +++ b/__tests__/plugins/toc.test.tsx @@ -175,4 +175,40 @@ export const toc = [ " `); }); + + it('preserves nesting when component names differ from exported elements', () => { + const md = ` +# Title + +## SubHeading + + + First + +`; + + const components = { + Comp: 'export const Comp = ({ children }) => { return children; }', + }; + + const compModule = run(compile(components.Comp)); + const { Toc } = run(compile(md, { components }), { + components: { + // 👇🏼 this is what we're guarding against + CompDoesNotMatchExportedModule: compModule, + }, + }); + + const html = renderToString(); + expect(html).toMatchInlineSnapshot(` + "" + `); + }); }); diff --git a/processor/plugin/toc.ts b/processor/plugin/toc.ts index b6d947d58..134a51dc4 100644 --- a/processor/plugin/toc.ts +++ b/processor/plugin/toc.ts @@ -58,14 +58,31 @@ export const rehypeToc = ({ components = {} }: Options): Transformer }; const MAX_DEPTH = 2; -const getDepth = (el: HastHeading) => parseInt(el.tagName?.match(/^h(\d)/)[1], 10); + +/** + * Get the depth of a heading element based on its tag name. + * + * ⚠️ Be extra defensive to non-heading elements somehow making it here. This + * should not happen, but if it does, we avoid breaking TOC depth calculations + * by returning `Infinity`, thus removing them from depth considerations. + * @link https://linear.app/readme-io/issue/CX-2543/tabapay-toc-does-not-respect-headers-nesting + * + * @example + * getDepth({ tagName: 'h1' }) // 1 + * getDepth({ tagName: 'h2' }) // 2 + */ +const getDepth = (el: HastHeading) => { + if (!el.tagName) return Infinity; + return parseInt(el.tagName?.match(/^h(\d)/)[1], 10); +}; /* * `tocToHast` consumes the list generated by `rehypeToc` and produces a hast * of nested lists to be rendered as a table of contents. */ const tocToHast = (headings: HastHeading[] = []): TocList => { - const min = Math.min(...headings.map(getDepth)); + const headingDepths = headings.map(getDepth); + const min = Math.min(...headingDepths); const ast = h('ul') as TocList; const stack: TocList[] = [ast]; @@ -105,7 +122,10 @@ export const tocHastToMdx = (toc: IndexableElements[] | undefined, components: R if (typeof toc === 'undefined') return ''; const injected = toc.flatMap(node => { - return node.type === 'mdxJsxFlowElement' && node.name in components ? components[node.name] || [] : node; + if (node.type === 'mdxJsxFlowElement') { + return components[node.name] || []; + } + return node; }); const tocHast = tocToHast(injected as HastHeading[]);