Skip to content

Commit 7140ee1

Browse files
committed
fix: Resolve hydration error on news blog pages
- Replace figure/div elements with span elements in img component to prevent invalid HTML nesting - Enhance paragraph component to detect and handle block elements more aggressively - Add client-side rendering check to prevent SSR/CSR mismatches - Convert paragraphs containing images to div elements to avoid <div> in <p> nesting - Remove deprecated allowDangerousHtml prop from ReactMarkdown Fixes React hydration error: 'In HTML, <div> cannot be a descendant of <p>' that was occurring when loading news blog posts with images.
1 parent e92069c commit 7140ee1

File tree

1 file changed

+118
-54
lines changed

1 file changed

+118
-54
lines changed

src/utils/MarkdownRenderer.tsx

Lines changed: 118 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useCallback } from 'react';
1+
import { useEffect, useMemo, useCallback, useState } from 'react';
22
import React, { Children } from 'react';
33
import ReactMarkdown, { Components } from 'react-markdown';
44
import remarkGfm from 'remark-gfm';
@@ -164,37 +164,37 @@ const processMarkdownContent = (content: string): string => {
164164
'<del class="line-through text-gray-500">$1</del>',
165165
);
166166

167-
// Collapsible sections
167+
// Collapsible sections - ensure proper block separation
168168
processed = processed.replace(
169169
/:::details\s+(.*?)\n([\s\S]*?):::/gim,
170-
'<details class="my-4 border border-gray-200 rounded-lg overflow-hidden bg-white">' +
171-
'<summary class="bg-gray-50 px-4 py-3 cursor-pointer font-medium text-gray-800 hover:bg-gray-100 transition-colors border-b border-gray-200">$1</summary>' +
172-
'<div class="px-4 py-3 text-gray-700">$2</div></details>',
170+
'\n\n<details class="my-4 border border-gray-200 rounded-lg overflow-hidden bg-white">' +
171+
'<summary class="bg-gray-50 px-4 py-3 cursor-pointer font-medium text-gray-800 hover:bg-gray-100 transition-colors border-b border-gray-200">$1</summary>' +
172+
'<div class="px-4 py-3 text-gray-700">$2</div></details>\n\n',
173173
);
174174

175-
// GitHub-style alerts
175+
// GitHub-style alerts - ensure proper block separation
176176
processed = processed.replace(
177177
/:::(\w+)\s*(.*?)\n([\s\S]*?):::/gim,
178178
(_, type, title, content) => {
179179
const alert =
180180
ALERT_TYPES[type as keyof typeof ALERT_TYPES] || ALERT_TYPES.note;
181-
return `<div class="my-4 p-4 border-l-4 ${alert.border} ${alert.bg} rounded-r-lg">
181+
return `\n\n<div class="my-4 p-4 border-l-4 ${alert.border} ${alert.bg} rounded-r-lg">
182182
<div class="flex items-center mb-2">
183183
<span class="mr-2 text-lg">${alert.icon}</span>
184184
<strong class="${alert.text} font-semibold uppercase text-sm tracking-wide">${type}${title ? `: ${title}` : ''}</strong>
185185
</div>
186186
<div class="${alert.text}">${content}</div>
187-
</div>`;
187+
</div>\n\n`;
188188
},
189189
);
190190

191-
// YouTube embeds
191+
// YouTube embeds - ensure proper block separation
192192
processed = processed.replace(
193193
/\[youtube:\s*([\w-]+)\]/gim,
194194
(_, videoId) =>
195-
`<div class="my-8 mx-auto max-w-4xl"><div class="relative rounded-xl shadow-lg overflow-hidden bg-black" style="aspect-ratio: 16/9;">
195+
`\n\n<div class="my-8 mx-auto max-w-4xl"><div class="relative rounded-xl shadow-lg overflow-hidden bg-black" style="aspect-ratio: 16/9;">
196196
<iframe src="https://www.youtube.com/embed/${videoId}?autoplay=0&rel=0&modestbranding=1" class="absolute inset-0 w-full h-full border-0"
197-
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" title="YouTube video player"></iframe></div></div>`,
197+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" title="YouTube video player"></iframe></div></div>\n\n`,
198198
);
199199

200200
return processed;
@@ -205,6 +205,12 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
205205
setZoomableImages,
206206
frontmatter,
207207
}) => {
208+
const [isClient, setIsClient] = useState(false);
209+
210+
useEffect(() => {
211+
setIsClient(true);
212+
}, []);
213+
208214
const processedContent = useMemo(
209215
() => processMarkdownContent(content),
210216
[content],
@@ -321,19 +327,19 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
321327

322328
const createHeading =
323329
(level: keyof typeof headingClasses) =>
324-
({
325-
children,
326-
...props
327-
}: React.HTMLAttributes<HTMLHeadingElement> & {
328-
children?: React.ReactNode;
329-
}) => {
330-
const Tag = level;
331-
return (
332-
<Tag {...props} className={headingClasses[level]}>
333-
{children}
334-
</Tag>
335-
);
336-
};
330+
({
331+
children,
332+
...props
333+
}: React.HTMLAttributes<HTMLHeadingElement> & {
334+
children?: React.ReactNode;
335+
}) => {
336+
const Tag = level;
337+
return (
338+
<Tag {...props} className={headingClasses[level]}>
339+
{children}
340+
</Tag>
341+
);
342+
};
337343

338344
const components: Components = {
339345
h1: createHeading('h1'),
@@ -343,14 +349,55 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
343349
h5: createHeading('h5'),
344350
h6: createHeading('h6'),
345351

346-
p: ({ children, ...props }) => (
347-
<p
348-
{...props}
349-
className="my-4 text-gray-700 dark:text-gray-300 leading-relaxed"
350-
>
351-
{children}
352-
</p>
353-
),
352+
p: ({ children, ...props }) => {
353+
// Convert children to array and check for problematic content
354+
const childArray = React.Children.toArray(children);
355+
356+
// Check if any child contains HTML that would create block elements or is an image
357+
const hasProblematicContent = childArray.some(child => {
358+
if (typeof child === 'string') {
359+
// Check for HTML tags that create block elements
360+
return /<(div|figure|blockquote|pre|table|ul|ol|details|iframe|h[1-6]|img)/i.test(child);
361+
}
362+
if (React.isValidElement(child)) {
363+
const type = child.type;
364+
// Check for React components that render as block elements or images
365+
return typeof type === 'string' &&
366+
['div', 'figure', 'blockquote', 'pre', 'table', 'ul', 'ol', 'details', 'iframe', 'img', 'span'].includes(type);
367+
}
368+
return false;
369+
});
370+
371+
// Check if this paragraph only contains an image
372+
const isImageOnly = childArray.length === 1 &&
373+
React.isValidElement(childArray[0]) &&
374+
(childArray[0].type === 'img' ||
375+
(typeof childArray[0].type === 'function' &&
376+
childArray[0].props && typeof childArray[0].props === 'object' &&
377+
'src' in childArray[0].props));
378+
379+
// If contains problematic content or is image-only, render as div
380+
if (hasProblematicContent || isImageOnly) {
381+
return (
382+
<div
383+
{...props}
384+
className="my-4 text-gray-700 dark:text-gray-300 leading-relaxed"
385+
>
386+
{children}
387+
</div>
388+
);
389+
}
390+
391+
// Safe to render as paragraph
392+
return (
393+
<p
394+
{...props}
395+
className="my-4 text-gray-700 dark:text-gray-300 leading-relaxed"
396+
>
397+
{children}
398+
</p>
399+
);
400+
},
354401

355402
blockquote: ({ children, ...props }) => (
356403
<blockquote
@@ -446,28 +493,30 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
446493
const imageSrc =
447494
src === '' && frontmatter?.image ? String(frontmatter.image) : src;
448495
return (
449-
<figure className="flex flex-col items-center my-6">
450-
<div className="overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-all duration-300 border border-gray-200">
451-
<img
452-
{...props}
453-
src={imageSrc}
454-
alt={alt}
455-
title={title || alt || ''}
456-
className="max-w-full h-auto rounded-lg object-contain hover:scale-105 transition-transform duration-300"
457-
data-zoomable="true"
458-
loading="lazy"
459-
onError={(e) => {
460-
(e.target as HTMLImageElement).src =
461-
'/assets/Images/SugarNewsLogo.webp';
462-
}}
463-
/>
464-
</div>
465-
{(title || alt) && (
466-
<figcaption className="text-center text-sm text-gray-600 mt-3 italic">
467-
{title || alt}
468-
</figcaption>
469-
)}
470-
</figure>
496+
<span className="block my-6">
497+
<span className="flex flex-col items-center">
498+
<span className="overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-all duration-300 border border-gray-200 inline-block">
499+
<img
500+
{...props}
501+
src={imageSrc}
502+
alt={alt}
503+
title={title || alt || ''}
504+
className="max-w-full h-auto rounded-lg object-contain hover:scale-105 transition-transform duration-300"
505+
data-zoomable="true"
506+
loading="lazy"
507+
onError={(e) => {
508+
(e.target as HTMLImageElement).src =
509+
'/assets/Images/SugarNewsLogo.webp';
510+
}}
511+
/>
512+
</span>
513+
{(title || alt) && (
514+
<span className="text-center text-sm text-gray-600 mt-3 italic block">
515+
{title || alt}
516+
</span>
517+
)}
518+
</span>
519+
</span>
471520
);
472521
},
473522

@@ -624,8 +673,23 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
624673
{children}
625674
</summary>
626675
),
676+
677+
627678
};
628679

680+
// Prevent hydration mismatch by only rendering on client
681+
if (!isClient) {
682+
return (
683+
<div className="prose prose-lg dark:prose-invert prose-headings:dark:text-gray-100 prose-p:dark:text-gray-300 prose-strong:dark:text-gray-100 prose-em:dark:text-gray-300 prose-li:dark:text-gray-300 max-w-none">
684+
<div className="animate-pulse">
685+
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
686+
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
687+
<div className="h-4 bg-gray-200 rounded w-5/6 mb-4"></div>
688+
</div>
689+
</div>
690+
);
691+
}
692+
629693
return (
630694
<div className="prose prose-lg dark:prose-invert prose-headings:dark:text-gray-100 prose-p:dark:text-gray-300 prose-strong:dark:text-gray-100 prose-em:dark:text-gray-300 prose-li:dark:text-gray-300 max-w-none">
631695
<ReactMarkdown

0 commit comments

Comments
 (0)