From a5832df5868d74728b825477c92ccb0b3872e82f Mon Sep 17 00:00:00 2001 From: Tyson Cung <45380903+tysoncung@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:07:55 +0800 Subject: [PATCH] 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 --- .../__tests__/paragraph.spec.tsx | 47 ++++++++++++++++--- .../__tests__/plugin-paragraph.spec.tsx | 23 ++++++++- .../base/markdown-blocks/paragraph.tsx | 33 +++++++------ .../base/markdown-blocks/plugin-paragraph.tsx | 10 ++-- .../components/base/markdown-blocks/utils.ts | 14 ++++++ 5 files changed, 97 insertions(+), 30 deletions(-) diff --git a/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx index 557eb96197..a220b5acfa 100644 --- a/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx @@ -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: [Text before, ], + }) + + 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: , + }) + + const wrapper = screen.getByRole('link').closest('.markdown-p') + expect(wrapper).toBeInTheDocument() + expect(wrapper!.tagName).toBe('DIV') + }) }) diff --git a/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx index 4e6637d337..52c56e0408 100644 --- a/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/plugin-paragraph.spec.tsx @@ -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( + + Text + , + ) + + expect(screen.getByTestId('image-fallback-paragraph')).toBeInTheDocument() + expect(screen.getByTestId('image-fallback-paragraph').tagName).toBe('DIV') + }) }) diff --git a/web/app/components/base/markdown-blocks/paragraph.tsx b/web/app/components/base/markdown-blocks/paragraph.tsx index adef509a31..af51d4ad0f 100644 --- a/web/app/components/base/markdown-blocks/paragraph.tsx +++ b/web/app/components/base/markdown-blocks/paragraph.tsx @@ -1,26 +1,25 @@ -/** - * @fileoverview Paragraph component for rendering

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 ( -

- - { - Array.isArray(paragraph.children) && paragraph.children.length > 1 && ( -
{paragraph.children.slice(1)}
- ) - } -
- ) + const hasImage = hasImageChild(children_node) + + if (hasImage) { + if (children_node[0]?.tagName === 'img') { + return ( +
+ + {Array.isArray(paragraph.children) && paragraph.children.length > 1 + ?
{paragraph.children.slice(1)}
+ : null} +
+ ) + } + return
{paragraph.children}
} + return

{paragraph.children}

} diff --git a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx index b9b4d5e873..810c953121 100644 --- a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx +++ b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx @@ -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

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 = ({ pluginInfo, no ) } + if (hasImageChild(childrenNode)) + return

{children}
+ return

{children}

} diff --git a/web/app/components/base/markdown-blocks/utils.ts b/web/app/components/base/markdown-blocks/utils.ts index 1ae59f5d43..1d087b3895 100644 --- a/web/app/components/base/markdown-blocks/utils.ts +++ b/web/app/components/base/markdown-blocks/utils.ts @@ -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)