Skip to content

[Blog] Fix React hydration errors (#418/#423) on blog pages - #8072

Draft
hortison wants to merge 1 commit into
masterfrom
work/busy-cannon-vsy1cj
Draft

hortison wants to merge 1 commit into
masterfrom
work/busy-cannon-vsy1cj

Conversation

@hortison

Copy link
Copy Markdown
Contributor

Description

This PR fixes #

Blog post pages logged dozens of React errors on every load, on production and on a local build: repeated Minified React error #418 (hydration failed, the initial UI does not match the server render) followed by #423 (React gave up and switched the entire root to client rendering, discarding the SSR output - slower first paint, layout shift, no SEO benefit from SSR).

Root cause

Rebuilding the production bundle against a non-minified React named it exactly:

Warning: validateDOMNesting(...): <p> cannot appear as a descendant of <p>.
Warning: Expected server HTML to contain a matching <p> in <p>.
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.

MDX parses the body of a multi-line JSX element as Markdown, so a post written as

<div className="intro">
  <p>
    You run <code>npx skill install my-skill</code>...
  </p>
</div>

compiles to <p><p>You run ...</p></p>. That markup is invalid, so the browser's parser closes the outer <p> before the nested one and the parsed DOM no longer matches the tree React renders on the client. Hydration then fails. The same thing happened wherever a heading, list, table, <div> or code block was written inside a hand-written <p>, and for every Markdown image, because the MDX image component wrapped each one in a <div> while sitting inside a paragraph.

This also explains why the error count tracked the post rather than the theme: it is identical in light and dark mode, and posts with no such markup were already clean. Date formatting, Math.random/Date.now, localStorage theme reads and the Related Blogs carousel were all ruled out - dates come from the GraphQL formatString at build time, and the carousel is already gated behind a post-hydration state flag.

The fix

All at the source - no suppressHydrationWarning:

  • rehype-fix-paragraph-nesting.js - a rehype plugin registered on gatsby-plugin-mdx that rewrites the tree the way the HTML parser would, so the emitted HTML is valid: nested paragraphs collapse into their parent (keeping whichever wrapper carries the attributes), and other block-level children are hoisted out of the paragraph as siblings. It also lifts a lone Markdown paragraph out of components that render a single text element, such as Typography.
  • root-wrapper.js - the MDX image wrapper is a display: block <span> instead of a <div>, which is valid inside the paragraph the image renders in and lays out identically.
  • Sistent color page - closed the paragraph before the list it contained.

Before / after

Lite blog build (BUILD_FULL_SITE=false LITE_BUILD_PROFILE=blog gatsby build), served with gatsby serve and loaded in headless Chromium, counting console errors, in both light and dark color schemes (identical results in each):

Page Before After
/blog/engineering/why-claude-code-cant-find-your-tools 53 (52x #418, 1x #423) 0
/blog/engineering/claude-code-skills-not-found-after-npx-install 34 (33x #418, 1x #423) 0
/blog/engineering/the-claude-code-source-leak-... 28 invalid nestings 0
/blog/ai/agentsmd-one-file-to-guide-them-all (control) 0 0
/blog/community/announcing-meshmates (control) 0 0
/blog (non-post) 0 0
/ (non-blog) 0 0

Scanning every page in the built site for block-level elements nested inside a <p>:

Before After
Pages affected 118 0
Total occurrences 517 0

Notes for Reviewers

  • The plugin is a pure tree transform with no new dependencies, and it only touches paragraphs that are already invalid - valid paragraphs are returned untouched.
  • Content authors do not have to change anything; existing and future posts are fixed at build time. Writing <p> around Markdown in MDX is still worth avoiding, but it is no longer a bug.
  • One page outside this change still fails hydration: /projects/sistent/components/accordion. Its <p> nesting is fixed, but MUI/emotion styles are not extracted during SSR there, so the server HTML carries 85 inline <style data-emotion> elements that do not exist in the client render. That needs an emotion SSR cache and is a separate change.
  • npx eslint reports no new problems; the pre-existing repo-wide lint failures are unchanged.

Signed commits

  • Yes, I signed my commits.

Generated by Claude Code

Blog posts logged dozens of console errors on every load - repeated
"Minified React error #418" (hydration failed, the initial UI does not
match the server render) followed by "#423" (React gave up and switched
the entire root to client rendering, discarding the SSR output).

Running the production bundle against a development React build named the
cause: "validateDOMNesting(...): <p> cannot appear as a descendant of <p>"
and "Expected server HTML to contain a matching <p> in <p>".

MDX parses the body of a multi-line JSX element as Markdown, so

    <p>
      Some text.
    </p>

compiles to <p><p>Some text.</p></p>. That markup is invalid, so the
browser closes the outer <p> before the nested one and the parsed DOM no
longer matches the tree React renders on the client. The same happened
wherever a heading, list, table, <div> or code block was written inside a
hand-written <p>, and for every Markdown image, because the MDX image
component wrapped each one in a <div> while sitting inside a paragraph.

Fixes, all at the source:

- Add a rehype plugin that rewrites the tree the way the HTML parser
  would: nested paragraphs collapse into their parent (keeping whichever
  wrapper carries the attributes), and other block-level children are
  hoisted out of the paragraph as siblings. It also lifts a lone Markdown
  paragraph out of components that render a single text element, such as
  Typography.
- Wrap MDX images in a display:block <span> instead of a <div>, which is
  valid inside the paragraph the image renders in.
- Close the paragraph before the list on the Sistent color page.

Verified with a lite blog build served locally and loaded in Chromium,
in both light and dark color schemes:

    /blog/engineering/why-claude-code-cant-find-your-tools   53 -> 0
    /blog/engineering/claude-code-skills-not-found-...       34 -> 0
    /blog, / (controls)                                       0 -> 0

Invalid block-in-paragraph nesting across the whole built site dropped
from 517 occurrences on 118 pages to zero.

Signed-off-by: Lee Calcote <leecalcote@gmail.com>
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Preview deployment for PR #8072 removed.

This PR preview was automatically pruned because we keep only the 3 most recently updated previews on GitHub Pages to stay within deployment size limits.

If needed, push a new commit to this PR to generate a fresh preview.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants