From 50f722bec0d7ef50bd737c8ac854e7df55ec56f4 Mon Sep 17 00:00:00 2001 From: Zhengyan Liu Date: Sat, 3 Oct 2026 18:26:50 +0800 Subject: [PATCH] fix(core): include JSX imports from nested editors --- src/examples/bug-733.tsx | 93 +++++++++ src/exportMarkdownFromLexical.ts | 23 ++- src/plugins/table/TableEditor.tsx | 3 +- .../nested-jsx-imports-integration.test.tsx | 177 ++++++++++++++++++ src/test/nested-jsx-imports.test.tsx | 153 +++++++++++++++ tests/browser/nested-jsx-imports.spec.ts | 99 ++++++++++ 6 files changed, 545 insertions(+), 3 deletions(-) create mode 100644 src/examples/bug-733.tsx create mode 100644 src/test/nested-jsx-imports-integration.test.tsx create mode 100644 src/test/nested-jsx-imports.test.tsx create mode 100644 tests/browser/nested-jsx-imports.spec.ts diff --git a/src/examples/bug-733.tsx b/src/examples/bug-733.tsx new file mode 100644 index 00000000..946ab68f --- /dev/null +++ b/src/examples/bug-733.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import { MDXEditor, type MDXEditorMethods } from '../MDXEditor' +import { AdmonitionDirectiveDescriptor } from '../directive-editors/AdmonitionDirectiveDescriptor' +import { directivesPlugin } from '../plugins/directives' +import { GenericJsxEditor } from '../jsx-editors/GenericJsxEditor' +import { insertJsx$, jsxPlugin } from '../plugins/jsx' +import { tablePlugin } from '../plugins/table' +import { toolbarPlugin } from '../plugins/toolbar' +import { Button } from '../plugins/toolbar/primitives/toolbar' +import { usePublisher } from '@mdxeditor/gurx' + +const initialMarkdown = `Root content + +:::tip +import Existing from '@existing' + +Admonition content +::: + +| Header | +| ------ | +| Table content | +` + +function InsertComponents() { + const insertJsx = usePublisher(insertJsx$) + return ( + <> + + + + ) +} + +export const NestedJsxImports = () => { + const ref = React.useRef(null) + const [markdown, setMarkdown] = React.useState(initialMarkdown) + const [exported, setExported] = React.useState('') + const [revision, setRevision] = React.useState(0) + return ( + <> + + + +
{exported}
+ + ) +} diff --git a/src/exportMarkdownFromLexical.ts b/src/exportMarkdownFromLexical.ts index 445b454b..0cc618fd 100644 --- a/src/exportMarkdownFromLexical.ts +++ b/src/exportMarkdownFromLexical.ts @@ -207,6 +207,27 @@ export function exportLexicalTreeToMdast({ throw new Error('traversal ended with no root element') } + const typedRoot = unistRoot as Mdast.Root + + // Decorators such as directives and tables contain MDAST subtrees rather than + // Lexical children, so their JSX references never reach the JSX visitor. + if (addImportStatements) { + const registerNestedComponents = (node: Mdast.Nodes) => { + if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && node.name !== null && !isMdastHTMLNode(node)) { + const descriptor = + jsxComponentDescriptors.find((descriptor) => descriptor.name === node.name) ?? + jsxComponentDescriptors.find((descriptor) => descriptor.name === '*') + if (descriptor?.source) { + registerReferredComponent(node.name) + } + } + if ('children' in node) { + node.children.forEach(registerNestedComponents) + } + } + registerNestedComponents(typedRoot) + } + // iterate over all referred components and construct import statements, then append them to the root const importsMap = new Map() const defaultImportsMap = new Map() @@ -271,8 +292,6 @@ export function exportLexicalTreeToMdast({ }) ) - const typedRoot = unistRoot as Mdast.Root - const frontmatter = typedRoot.children.find((child) => child.type === 'yaml') if (frontmatter) { typedRoot.children.splice(typedRoot.children.indexOf(frontmatter) + 1, 0, ...imports) diff --git a/src/plugins/table/TableEditor.tsx b/src/plugins/table/TableEditor.tsx index 5f399bd2..65faa9cd 100644 --- a/src/plugins/table/TableEditor.tsx +++ b/src/plugins/table/TableEditor.tsx @@ -478,7 +478,8 @@ const CellEditor: React.FC = ({ focus, setActiveCell, parentEditor, l root: $getRoot(), jsxComponentDescriptors, visitors: exportVisitors, - jsxIsAvailable + jsxIsAvailable, + addImportStatements: false }) parentEditor.update( () => { diff --git a/src/test/nested-jsx-imports-integration.test.tsx b/src/test/nested-jsx-imports-integration.test.tsx new file mode 100644 index 00000000..a8ceed93 --- /dev/null +++ b/src/test/nested-jsx-imports-integration.test.tsx @@ -0,0 +1,177 @@ +import React from 'react' +import { act, fireEvent, render, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $getRoot, $isElementNode, type LexicalEditor } from 'lexical' +import { addNestedEditorChild$, addTableCellEditorChild$ } from '../plugins/core' +import { AdmonitionDirectiveDescriptor } from '../directive-editors/AdmonitionDirectiveDescriptor' +import { directivesPlugin } from '../plugins/directives' +import { tablePlugin } from '../plugins/table' +import { GenericJsxEditor } from '../jsx-editors/GenericJsxEditor' +import { jsxPlugin } from '../plugins/jsx' +import { MDXEditor, type MDXEditorMethods } from '../MDXEditor' +import { realmPlugin } from '../RealmWithPlugins' +import { $createLexicalJsxNode } from '../plugins/jsx/LexicalJsxNode' + +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true + +it('imports a JSX component inserted into an admonition at the document root', async () => { + let nestedEditor: LexicalEditor | null = null + function CaptureNestedEditor() { + const [editor] = useLexicalComposerContext() + nestedEditor ??= editor + return null + } + const captureNestedEditorPlugin = realmPlugin({ + init(realm) { + realm.pub(addNestedEditorChild$, CaptureNestedEditor) + } + }) + const ref = React.createRef() + const { container } = render( + + ) + await waitFor(() => { + expect(nestedEditor).not.toBeNull() + expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(2) + }) + act(() => { + nestedEditor!.update( + () => { + $getRoot().append($createLexicalJsxNode({ type: 'mdxJsxFlowElement', name: 'Zazz', attributes: [], children: [] })) + }, + { discrete: true } + ) + }) + fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1]) + await waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('') + }) + expect(ref.current?.getMarkdown()).toMatch(/^import Zazz from '@zazz'/) +}) + +it('preserves existing nested imports when a new component is inserted and the document is reloaded', async () => { + let nestedEditor: LexicalEditor | null = null + function CaptureNestedEditor() { + const [editor] = useLexicalComposerContext() + nestedEditor ??= editor + return null + } + const capture = realmPlugin({ + init: (realm) => { + realm.pub(addNestedEditorChild$, CaptureNestedEditor) + } + }) + const ref = React.createRef() + const { container } = render( + \n:::"} + plugins={[ + capture(), + directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }), + jsxPlugin({ + jsxComponentDescriptors: [ + { name: 'Zazz', kind: 'flow', source: '@zazz', defaultExport: true, props: [], hasChildren: false, Editor: GenericJsxEditor }, + { name: '*', kind: 'flow', props: [], hasChildren: false, Editor: GenericJsxEditor } + ] + }) + ]} + /> + ) + await waitFor(() => { + expect(nestedEditor).not.toBeNull() + }) + act(() => { + nestedEditor!.update( + () => { + $getRoot().append($createLexicalJsxNode({ type: 'mdxJsxFlowElement', name: 'Zazz', attributes: [], children: [] })) + }, + { discrete: true } + ) + }) + fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1]) + await waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('') + }) + const markdown = ref.current!.getMarkdown() + expect(markdown).toMatch(/^import Zazz from '@zazz'/) + expect(markdown).toContain(":::tip\nimport Existing from '@existing'") + expect(markdown.match(/import Existing/g)).toHaveLength(1) + act(() => { + ref.current!.setMarkdown(markdown) + }) + await waitFor(() => { + expect(ref.current!.getMarkdown()).toBe(markdown) + }) +}) + +it('exports a component inserted into a table cell without losing the cell content', async () => { + const tableEditors: LexicalEditor[] = [] + function CaptureTableEditor() { + const [editor] = useLexicalComposerContext() + if (!tableEditors.includes(editor)) tableEditors.push(editor) + return null + } + const capture = realmPlugin({ + init: (realm) => { + realm.pub(addTableCellEditorChild$, CaptureTableEditor) + } + }) + const ref = React.createRef() + const { container } = render( + + ) + await waitFor(() => { + expect(tableEditors).toHaveLength(2) + }) + act(() => { + tableEditors[1].update( + () => { + const paragraph = $getRoot().getFirstChildOrThrow() + if (!$isElementNode(paragraph)) throw new Error('Expected a table-cell paragraph') + paragraph.append($createLexicalJsxNode({ type: 'mdxJsxTextElement', name: 'Badge', attributes: [], children: [] })) + }, + { discrete: true } + ) + }) + fireEvent.blur(container.querySelector('td [contenteditable="true"]')!) + await waitFor(() => { + expect(ref.current?.getMarkdown()).toContain('') + }) + expect(ref.current?.getMarkdown()).toMatch(/^import \{ Badge \} from '@components'/) + expect(ref.current?.getMarkdown()).toContain('Cell') +}) diff --git a/src/test/nested-jsx-imports.test.tsx b/src/test/nested-jsx-imports.test.tsx new file mode 100644 index 00000000..2302d77a --- /dev/null +++ b/src/test/nested-jsx-imports.test.tsx @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { $getRoot, createEditor, type LexicalNode } from 'lexical' +import type * as Mdast from 'mdast' +import type { ContainerDirective } from 'mdast-util-directive' +import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx' +import { exportLexicalTreeToMdast, type ExportLexicalTreeOptions } from '../exportMarkdownFromLexical' +import { $createDirectiveNode, DirectiveNode } from '../plugins/directives/DirectiveNode' +import { DirectiveVisitor } from '../plugins/directives/DirectiveVisitor' +import { LexicalRootVisitor } from '../plugins/core/LexicalRootVisitor' +import { $createLexicalJsxNode, LexicalJsxNode } from '../plugins/jsx/LexicalJsxNode' +import { LexicalJsxVisitor } from '../plugins/jsx/LexicalJsxVisitor' +import type { JsxComponentDescriptor } from '../plugins/jsx' +import { $createTableNode, TableNode } from '../plugins/table/TableNode' +import { LexicalTableVisitor } from '../plugins/table/LexicalTableVisitor' + +function jsx(name: string | null, children: Mdast.BlockContent[] = []): MdxJsxFlowElement { + return { type: 'mdxJsxFlowElement', name, attributes: [], children } +} + +function descriptor(name: string, source?: string, defaultExport = false): JsxComponentDescriptor { + return { name, kind: 'flow', source, defaultExport, props: [], hasChildren: false, Editor: () => null } +} + +function admonition(children: Mdast.RootContent[]) { + return $createDirectiveNode({ + type: 'containerDirective', + name: 'tip', + attributes: {}, + children: children as ContainerDirective['children'] + }) +} + +function exportNodes(createNodes: () => LexicalNode[], descriptors: JsxComponentDescriptor[], addImportStatements = true) { + const editor = createEditor({ + nodes: [DirectiveNode, LexicalJsxNode, TableNode], + onError: (error) => { + throw error + } + }) + let result!: Mdast.Root + editor.update( + () => { + const root = $getRoot() + root.append(...createNodes()) + result = exportLexicalTreeToMdast({ + root, + visitors: [ + LexicalRootVisitor, + DirectiveVisitor, + LexicalJsxVisitor, + LexicalTableVisitor + ] as unknown as ExportLexicalTreeOptions['visitors'], + jsxComponentDescriptors: descriptors, + jsxIsAvailable: true, + addImportStatements + }) + }, + { discrete: true } + ) + return result +} + +function importValues(root: Mdast.Root) { + return root.children.filter((node) => node.type === 'mdxjsEsm').map((node) => node.value) +} + +describe('imports from nested MDAST subtrees', () => { + it('imports a default JSX component inside an admonition at the document root', () => { + const result = exportNodes(() => [admonition([jsx('Zazz')])], [descriptor('Zazz', '@zazz', true)]) + expect(importValues(result)).toEqual(["import Zazz from '@zazz'"]) + }) + + it('groups named imports and keeps default imports separate', () => { + const result = exportNodes( + () => [admonition([jsx('First'), jsx('Second'), jsx('Default')])], + [descriptor('First', '@components'), descriptor('Second', '@components'), descriptor('Default', '@default', true)] + ) + expect(importValues(result)).toEqual(["import { First, Second } from '@components'", "import Default from '@default'"]) + }) + + it('deduplicates references shared by the root and multiple nested editors', () => { + const result = exportNodes( + () => [$createLexicalJsxNode(jsx('Zazz')), admonition([jsx('Zazz'), jsx('Zazz')]), admonition([jsx('Zazz')])], + [descriptor('Zazz', '@zazz', true)] + ) + expect(importValues(result)).toEqual(["import Zazz from '@zazz'"]) + }) + + it('finds JSX references inside table cells', () => { + const result = exportNodes( + () => [ + $createTableNode({ + type: 'table', + children: [ + { + type: 'tableRow', + children: [{ type: 'tableCell', children: [{ ...jsx('Badge'), type: 'mdxJsxTextElement', children: [] }] }] + } + ] + }) + ], + [descriptor('Badge', '@components')] + ) + expect(importValues(result)).toEqual(["import { Badge } from '@components'"]) + }) + + it('does not import HTML elements or fragments, but visits their component children', () => { + const result = exportNodes(() => [admonition([jsx(null, [jsx('span', [jsx('Section')])])])], [descriptor('*', '@components')]) + expect(importValues(result)).toEqual(["import { Section } from '@components'"]) + }) + + it('does not invent imports for unknown components', () => { + const result = exportNodes(() => [admonition([jsx('Unknown')])], []) + expect(importValues(result)).toEqual([]) + }) + + it('uses wildcard descriptor sources only when there is no exact descriptor', () => { + const result = exportNodes( + () => [admonition([jsx('Exact'), jsx('Fallback'), jsx('NoImport')])], + [descriptor('*', '@fallback'), descriptor('Exact', '@exact', true), descriptor('NoImport')] + ) + expect(importValues(result)).toEqual(["import { Fallback } from '@fallback'", "import Exact from '@exact'"]) + }) + + it('does not add imports for a source-less wildcard descriptor', () => { + const result = exportNodes(() => [admonition([jsx('Unknown')])], [descriptor('*')]) + expect(importValues(result)).toEqual([]) + }) + + it('keeps existing nested imports in place, as required by #734', () => { + const existingImport = { type: 'mdxjsEsm' as const, value: "import Existing from '@existing'" } + const result = exportNodes( + () => [admonition([existingImport, jsx('Existing'), jsx('Zazz')])], + [descriptor('*'), descriptor('Zazz', '@zazz', true)] + ) + expect(importValues(result)).toEqual(["import Zazz from '@zazz'"]) + expect(result.children[1]).toMatchObject({ type: 'containerDirective', children: [existingImport, jsx('Existing'), jsx('Zazz')] }) + }) + + it('does not generate fresh imports during a nested-editor export', () => { + const result = exportNodes(() => [admonition([jsx('Zazz')])], [descriptor('Zazz', '@zazz', true)], false) + expect(importValues(result)).toEqual([]) + }) + + it('preserves the original JSX import source when nested export suppresses new imports', () => { + const result = exportNodes( + () => [$createLexicalJsxNode(jsx('Existing'), { source: '@existing', defaultExport: true })], + [descriptor('*')], + false + ) + expect(importValues(result)).toEqual(["import Existing from '@existing'"]) + }) +}) diff --git a/tests/browser/nested-jsx-imports.spec.ts b/tests/browser/nested-jsx-imports.spec.ts new file mode 100644 index 00000000..565c4d7e --- /dev/null +++ b/tests/browser/nested-jsx-imports.spec.ts @@ -0,0 +1,99 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { mdxFromMarkdown } from 'mdast-util-mdx' +import { directiveFromMarkdown } from 'mdast-util-directive' +import { gfmTableFromMarkdown } from 'mdast-util-gfm-table' +import { mdxjs } from 'micromark-extension-mdxjs' +import { directive } from 'micromark-extension-directive' +import { gfmTable } from 'micromark-extension-gfm-table' + +async function placeCaretAtEnd(element: Locator) { + await element.click() + const editable = element.locator('xpath=ancestor-or-self::*[@contenteditable="true"][1]') + await expect(editable).toBeFocused() + await editable.press('End') +} + +function withoutPositions(value: unknown, inEstree = false): unknown { + if (Array.isArray(value)) return value.map((item: unknown) => withoutPositions(item, inEstree)) + if (value === null || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== 'position' && !(inEstree && ['start', 'end', 'loc', 'range'].includes(key))) + .map(([key, child]) => [key, withoutPositions(child, inEstree || key === 'estree')]) + ) +} + +function parseContent(markdown: string) { + const tree = fromMarkdown(markdown, { + extensions: [mdxjs(), directive(), gfmTable()], + mdastExtensions: [mdxFromMarkdown(), directiveFromMarkdown(), gfmTableFromMarkdown()] + }) + // Only source coordinates change when blank lines normalize on reload. Keep + // all nodes, attributes, imports, parser metadata, and semantic list starts. + return withoutPositions(tree) +} + +async function readMarkdown(page: Page) { + await page.getByRole('button', { name: 'Get Markdown', exact: true }).click() + return (await page.getByLabel('Exported markdown').textContent()) ?? '' +} + +const occurrences = (text: string, search: string) => text.split(search).length - 1 + +test.beforeEach(async ({ page }) => { + await page.goto('/?story=bug-733--nested-jsx-imports&mode=preview') + await expect(page.getByRole('button', { name: 'Insert Zazz', exact: true })).toBeVisible() +}) + +test('inserting JSX into an admonition exports its import at the document root', async ({ page }) => { + await placeCaretAtEnd( + page + .locator('p') + .filter({ hasText: /^Admonition content/ }) + .last() + ) + await page.getByRole('button', { name: 'Insert Zazz', exact: true }).click() + await expect(page.getByText('Zazz', { exact: true })).toHaveCount(1) + const markdown = await readMarkdown(page) + expect(markdown, 'Nested JSX must receive a root-level import').toMatch(/^import Zazz from '@zazz'/) + expect(markdown).toContain('') + expect(markdown).toContain(":::tip\nimport Existing from '@existing'") + expect(occurrences(markdown, "import Zazz from '@zazz'")).toBe(1) + await page.getByRole('button', { name: 'Reload saved Markdown', exact: true }).click() + expect(parseContent(await readMarkdown(page))).toEqual(parseContent(markdown)) +}) + +test('root and nested references share one import after repeated insertion and reload', async ({ page }) => { + await placeCaretAtEnd(page.getByText('Root content', { exact: true })) + await page.getByRole('button', { name: 'Insert Zazz', exact: true }).click() + await expect(page.getByText('Zazz', { exact: true })).toHaveCount(1) + await placeCaretAtEnd( + page + .locator('p') + .filter({ hasText: /^Admonition content/ }) + .last() + ) + await page.getByRole('button', { name: 'Insert Zazz', exact: true }).click() + await expect(page.getByText('Zazz', { exact: true })).toHaveCount(2) + const markdown = await readMarkdown(page) + expect(occurrences(markdown, '')).toBe(2) + expect(occurrences(markdown, "import Zazz from '@zazz'")).toBe(1) + await page.getByRole('button', { name: 'Reload saved Markdown', exact: true }).click() + expect(parseContent(await readMarkdown(page))).toEqual(parseContent(markdown)) +}) + +test('inserting inline JSX into a table preserves the cell and imports the component once', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await placeCaretAtEnd(page.getByRole('cell', { name: 'Table content', exact: true }).locator('p')) + await page.getByRole('button', { name: 'Insert Badge', exact: true }).click() + await expect(page.getByText('Badge', { exact: true })).toHaveCount(1) + const markdown = await readMarkdown(page) + expect(markdown).toMatch(/^import \{ Badge \} from '@components'/) + expect(markdown).toContain('Table content') + expect(occurrences(markdown, "import { Badge } from '@components'")).toBe(1) + expect(errors).toEqual([]) + await page.getByRole('button', { name: 'Reload saved Markdown', exact: true }).click() + expect(parseContent(await readMarkdown(page))).toEqual(parseContent(markdown)) +})