fix: prevent hydration warning from div nesting inside p for inline markdown images (#32419)

Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Tyson Cung 2026-03-10 14:07:55 +08:00 committed by GitHub
parent a808389122
commit a5832df586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 97 additions and 30 deletions

View File

@ -8,13 +8,14 @@ vi.mock('@/app/components/base/image-gallery', () => ({
),
}))
type MockChildNode = {
tagName?: string
properties?: { src?: string }
children?: MockChildNode[]
}
type MockNode = {
children?: Array<{
tagName?: string
properties?: {
src?: string
}
}>
children?: MockChildNode[]
}
type ParagraphProps = {
@ -93,4 +94,38 @@ describe('Paragraph', () => {
expect(screen.getByText('Fallback').tagName).toBe('P')
})
it('should render div instead of p when image is not the first child', () => {
renderParagraph({
node: {
children: [
{ tagName: 'span' },
{ tagName: 'img', properties: { src: 'test.png' } },
],
},
children: [<span key="0">Text before</span>, <img key="1" src="test.png" alt="" />],
})
const wrapper = screen.getByText('Text before').closest('.markdown-p')
expect(wrapper).toBeInTheDocument()
expect(wrapper!.tagName).toBe('DIV')
})
it('should render div when image is nested inside a link', () => {
renderParagraph({
node: {
children: [
{
tagName: 'a',
children: [{ tagName: 'img', properties: { src: 'nested.png' } }],
},
],
},
children: <a href="#"><img src="nested.png" alt="" /></a>,
})
const wrapper = screen.getByRole('link').closest('.markdown-p')
expect(wrapper).toBeInTheDocument()
expect(wrapper!.tagName).toBe('DIV')
})
})

View File

@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { PluginParagraph } from '../plugin-paragraph'
import { getMarkdownImageURL } from '../utils'
import { getMarkdownImageURL, hasImageChild } from '../utils'
// Mock dependencies
vi.mock('@/service/use-plugins', () => ({
@ -13,6 +13,7 @@ vi.mock('@/service/use-plugins', () => ({
vi.mock('../utils', () => ({
getMarkdownImageURL: vi.fn(),
hasImageChild: vi.fn((): boolean => false),
}))
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
@ -178,4 +179,24 @@ describe('PluginParagraph', () => {
await user.click(closeBtn)
expect(screen.queryByTestId('image-preview-modal')).not.toBeInTheDocument()
})
it('should render div instead of p when image is not the first child', () => {
vi.mocked(hasImageChild).mockReturnValue(true)
const node: MockNode = {
children: [
{ tagName: 'span' },
{ tagName: 'img', properties: { src: 'test.png' } },
],
}
render(
<PluginParagraph node={node}>
<span>Text</span>
</PluginParagraph>,
)
expect(screen.getByTestId('image-fallback-paragraph')).toBeInTheDocument()
expect(screen.getByTestId('image-fallback-paragraph').tagName).toBe('DIV')
})
})

View File

@ -1,26 +1,25 @@
/**
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
* Extracted from the main markdown renderer for modularity.
* Handles special rendering for paragraphs that directly contain an image.
*/
import * as React from 'react'
import ImageGallery from '@/app/components/base/image-gallery'
import { hasImageChild } from './utils'
const Paragraph = (paragraph: any) => {
const { node }: any = paragraph
const children_node = node.children
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
return (
<div className="markdown-img-wrapper">
<ImageGallery srcs={[children_node[0].properties.src]} />
{
Array.isArray(paragraph.children) && paragraph.children.length > 1 && (
<div className="mt-2">{paragraph.children.slice(1)}</div>
)
}
</div>
)
const hasImage = hasImageChild(children_node)
if (hasImage) {
if (children_node[0]?.tagName === 'img') {
return (
<div className="markdown-img-wrapper">
<ImageGallery srcs={[children_node[0].properties.src]} />
{Array.isArray(paragraph.children) && paragraph.children.length > 1
? <div className="mt-2">{paragraph.children.slice(1)}</div>
: null}
</div>
)
}
return <div className="markdown-p">{paragraph.children}</div>
}
return <p>{paragraph.children}</p>
}

View File

@ -1,14 +1,9 @@
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
/**
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
* Extracted from the main markdown renderer for modularity.
* Handles special rendering for paragraphs that directly contain an image.
*/
import ImageGallery from '@/app/components/base/image-gallery'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { getMarkdownImageURL } from './utils'
import { getMarkdownImageURL, hasImageChild } from './utils'
type PluginParagraphProps = {
pluginInfo?: SimplePluginInfo
@ -66,5 +61,8 @@ export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, no
</div>
)
}
if (hasImageChild(childrenNode))
return <div className="markdown-p" data-testid="image-fallback-paragraph">{children}</div>
return <p data-testid="standard-paragraph">{children}</p>
}

View File

@ -1,5 +1,19 @@
import { ALLOW_UNSAFE_DATA_SCHEME, MARKETPLACE_API_PREFIX } from '@/config'
type MdastNode = {
tagName?: string
children?: MdastNode[]
[key: string]: unknown
}
export const hasImageChild = (children: MdastNode[] | undefined): boolean => {
return children?.some((child) => {
if (child.tagName === 'img')
return true
return child.children ? hasImageChild(child.children) : false
}) ?? false
}
export const isValidUrl = (url: string): boolean => {
const validPrefixes = ['http:', 'https:', '//', 'mailto:']
if (ALLOW_UNSAFE_DATA_SCHEME)