The “unexpected or invalid content” error is a diff, not corruption

Hand-drawn whiteboard diagram: the block editor compares stored post content against what the block's save function would generate; a missing class in the stored copy triggers the unexpected-or-invalid-content error. The fix is copying canonical markup from the block instead of writing it by hand.

I hand-wrote a separator into a theme pattern. Simple block, one line of markup, what could go wrong. The editor answered with its scariest sentence: “This block contains unexpected or invalid content,” a warning triangle, and a button offering to attempt recovery, the way one offers to attempt CPR.

The word “invalid” does a lot of frightening here, so let’s take it apart. Nothing is corrupted. Your content is intact, and the front end is rendering it right now without complaint (this error lives only in the editor). What actually happened is a disagreement between two authors.

Here’s the mechanism, read from WordPress 7.0.2. Every time the editor loads a block, it parses the attributes out of the block’s HTML comment, hands them to the block’s save() function, and asks: given these attributes, what markup would you write today? Then it compares that answer against the markup actually stored in post_content. The functions doing this live in wp-includes/js/dist/blocks.js, and their log strings say it plainly: “Content generated by save function” versus “Content retrieved from post body.” Two authors. One is the block’s code; the other is whoever wrote the stored markup. When they disagree, the editor refuses to guess who’s right and shows you the triangle.

The comparison is smarter than string equality, and knowing its rules tells you what can and cannot break:

  • It forgives formatting. Both sides are tokenized; whitespace between tags is skipped, tag-name case is ignored, and attributes are compared as unordered sets. Reordering class and style breaks nothing.
  • It forgives nothing of substance. An attribute the save() version doesn’t expect, a missing class, a different tag: each one fails the block, and the comparison stops at the first disagreement.

My separator failed on exactly that second rule. Core’s save() writes separators with a class called has-alpha-channel-opacity, which encodes the block’s opacity handling. My hand-written line didn’t have it, because I didn’t know it existed, because I wrote the markup instead of asking the block to. The stored copy and the code’s copy disagreed by one class, and one class is all it takes.

What the buttons do, briefly, since the official docs tour them well: Attempt Block Recovery accepts the code’s version of reality, regenerating the markup from the parsed attributes. It usually works, and it’s usually right, since the block’s code is usually the author you should trust. The console meanwhile logs the full Expected and Actual markup on every failure, so if you want to know precisely which class or attribute is at war, open devtools and read the diff before you click anything.

But the durable fix is to stop creating the disagreement:

  1. Never write block markup by hand. Insert the block in the editor, configure it, switch to the code editor view, and copy what save() wrote. That output is canonical by definition; it cannot disagree with itself.
  2. **Check the block’s block.json** before assuming an attribute exists or a class is optional. The supports list is the block’s vocabulary.
  3. If you ship patterns, validate them with WordPress’s own validator. The same validateBlock machinery is importable in a script; my build runs every pattern through it and fails before anything invalid ships. The editor and the build then agree by construction.

The reframe worth keeping: this error is the block editor’s contract being enforced. A block’s save() function is the single source of truth for its markup, and the validator is just the clause that catches anyone else’s handwriting in the ledger. Which, that day, was mine.