Short answer
You can stop “This block contains unexpected or invalid content” reaching editors by running the editor’s own parser over saved-markup fixtures in CI. Keep a fixture of every markup version a block has ever saved. Parse each one with @wordpress/blocks, and fail the build if any block comes back invalid. Validate every block.json against the official schema in the same job. Together these catch a missing deprecation before it merges, not after editors open old posts.
The usual failure goes like this. Someone tidies a block’s markup, adds a wrapper or renames a class, and every post saved with the old markup now fails validation the next time it is opened. Nothing breaks on the front end and nobody notices in review. Editors find it weeks later. This article builds the gate that catches it, using the same validation code the editor runs rather than a home-made approximation.
How block validation actually works
Gutenberg stores a static block as an HTML comment holding its JSON attributes, wrapped around the output of the block’s save() function:
<!-- wp:acme/callout {"tone":"warning","text":"Mind the gap"} -->
<aside class="wp-block-acme-callout callout callout--warning" role="note"><p>Mind the gap</p></aside>
<!-- /wp:acme/callout -->
When the editor loads a post, it parses the attributes, runs the current save() with them, and compares the result to the stored HTML. The handbook describes this as a match check. In the source it is an equivalence check, not a byte-for-byte comparison: isEquivalentHTML() in @wordpress/blocks tokenises both sides, collapses whitespace, ignores the order of class names and compares inline styles property by property. That is why harmless formatting changes do not invalidate content, while a changed tag, an added attribute or a new class does.
If the comparison fails, the parser applies a few built-in fixes, then tries each entry in the block’s deprecated array, newest first. A deprecation whose save() reproduces the stored markup wins, and its migrate() converts the old attributes. If nothing matches, the block is marked invalid and the editor shows the recovery prompt. The deprecation documentation itself recommends keeping fixtures of every version of a block’s content for exactly this reason.
What the gate checks
| Check | Catches | Runs on |
|---|---|---|
block.json schema validation |
Typos in supports, invalid attribute types, an outdated apiVersion |
Every PR |
| Fixture parsing | A save() change with no deprecation; a deprecation that no longer reproduces old markup |
Every PR touching blocks |
| Current-markup round trip | Unintended serialisation changes in the current version | Every PR touching blocks |
| Production corpus | Markup you never captured as a fixture | Nightly or before release |
Step 1: validate block.json against the official schema
WordPress publishes the block.json schema at schemas.wp.org. It is a JSON Schema draft-07 document, so Ajv’s default class handles it. Commit a copy rather than fetching trunk on every run, so a schema change upstream cannot fail an unrelated PR. Update the copy deliberately.
// scripts/lint-block-json.mjs
// Usage: node scripts/lint-block-json.mjs src/blocks/*/block.json
import Ajv from 'ajv';
import { readFileSync } from 'node:fs';
// A pinned copy of https://schemas.wp.org/trunk/block.json, committed to the repo.
const schema = JSON.parse( readFileSync( new URL( './block.schema.json', import.meta.url ), 'utf8' ) );
const validate = new Ajv( { allErrors: true, strict: false } ).compile( schema );
let failed = false;
for ( const file of process.argv.slice( 2 ) ) {
const metadata = JSON.parse( readFileSync( file, 'utf8' ) );
if ( ! validate( metadata ) ) {
failed = true;
for ( const error of validate.errors ) {
console.error( `${ file }: ${ error.instancePath || '(root)' } ${ error.message }` );
}
}
}
process.exit( failed ? 1 : 0 );
One thing will surprise older codebases. The current schema requires "apiVersion": 3, so blocks still on version 2 fail. That is intentional: version 3 is what the iframed editor expects. Either migrate those blocks or pin an older copy of the schema while you do.
Step 2: keep a fixture for every markup version
Store fixtures next to the block:
src/blocks/callout/
block.json
save.jsx
deprecated.jsx
fixtures/
v1.html # markup saved by the first release, never edited again
current.html # markup the current save() produces
Capture a fixture from the code editor view of a real post before you change save(). Then treat it as immutable. Editing an old fixture to make the build pass defeats the whole gate. When you change the markup, the old current.html becomes v2.html, a new current.html is captured, and a deprecation entry is added. The PR that changes save() without that third step is the one the gate fails.
Step 3: parse fixtures with the real parser
The original version of this tool rendered save() with renderToString and compared strings. That is stricter than the editor in some places and looser in others, and it skips the built-in fixes and the migration path. Calling parse() from @wordpress/blocks runs the same validation, fixes and deprecations as the editor, and records isValid on every block.
The test below uses Vitest with a jsdom environment. Current @wordpress/scripts runs your installed Vitest through wp-scripts test-unit-js, so this works there too. Assumptions: block source files use .jsx, and save() spreads useBlockProps.save() as any apiVersion 3 block should.
// test/callout.test.js
// @vitest-environment jsdom
import { readdirSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
import { parse, registerBlockType, serialize } from '@wordpress/blocks';
import metadata from '../src/blocks/callout/block.json';
import save from '../src/blocks/callout/save.jsx';
import deprecated from '../src/blocks/callout/deprecated.jsx';
// Plain paths: the jsdom environment replaces the global URL class.
const fixtures = resolve( 'src/blocks/callout/fixtures' );
// Walk nested blocks so an invalid child inside a valid parent still fails.
function findInvalid( blocks, found = [] ) {
for ( const block of blocks ) {
if ( ! block.isValid ) {
found.push( block.name );
}
findInvalid( block.innerBlocks, found );
}
return found;
}
beforeAll( () => {
registerBlockType( metadata, { edit: () => null, save, deprecated } );
} );
describe( 'acme/callout stored markup', () => {
const files = readdirSync( fixtures ).filter( ( f ) => f.endsWith( '.html' ) );
it.each( files )( '%s parses without validation errors', ( file ) => {
const blocks = parse( readFileSync( join( fixtures, file ), 'utf8' ) );
expect( blocks.map( ( b ) => b.name ) ).toContain( metadata.name );
expect( findInvalid( blocks ) ).toEqual( [] );
} );
it( 'current.html round-trips unchanged', () => {
const html = readFileSync( join( fixtures, 'current.html' ), 'utf8' ).trim();
expect( serialize( parse( html ) ) ).toBe( html );
} );
} );
Two pieces of configuration are easy to miss:
// vitest.config.mjs
import { defineConfig } from 'vitest/config';
export default defineConfig( {
test: {
// Let Vite transform @wordpress/* packages, which import JSON
// without import attributes and fail when loaded by Node directly.
server: { deps: { inline: [ /@wordpress\// ] } },
},
} );
- Block supports must be registered. The generated
wp-block-acme-calloutclass, custom class names, anchors and colour classes are added by hooks in@wordpress/block-editor. ImportinguseBlockPropsinsave.jsxloads them as a side effect. A save function that skipsuseBlockProps.save()produces markup the editor would reject, and the test reports exactly that. This is useful, not a false alarm. - Nested core blocks need registering too. If fixtures contain
core/paragraphor other core blocks as inner blocks, callregisterCoreBlocks()from@wordpress/block-libraryinbeforeAll.
The failures read like the editor’s console. Change save() and current.html fails. Delete the deprecation and v1.html fails with Block validation: Expected tag name `aside`, instead saw `div`. Restore it and the old fixture passes again through the migration path.
Step 4: check real content, not only fixtures
Fixtures only cover markup you remembered to capture. Content created before the gate existed, or edited through “Edit as HTML” before you set "supports": { "html": false }, can still hold shapes you have never seen. A nightly job can export real markup and run the same parser over it:
# Export post_content containing your namespace's blocks (adjust prefix and statuses).
mkdir -p corpus
wp db query "SELECT ID FROM $(wp db prefix)posts
WHERE post_content LIKE '%<!-- wp:acme/%'
AND post_status IN ('publish','draft','future','private')" --skip-column-names \
| while read -r id; do
wp post get "$id" --field=post_content > "corpus/$id.html"
done
Point a second test at corpus/ using the same findInvalid() helper. Keep the corpus out of the repository and out of build artefacts: it is client content.
Step 5: make it a required check
# .github/workflows/blocks.yml
name: Block validation
on:
pull_request:
paths:
- 'src/blocks/**'
- 'package-lock.json'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: node scripts/lint-block-json.mjs src/blocks/*/block.json
- run: npx vitest run
Mark the job as a required status check on the protected branch. Include package-lock.json in the trigger paths on purpose: upgrading @wordpress/* packages can change what block supports output, and you want to find that out in the upgrade PR. The same “fail the PR, not production” approach applies to API shapes, covered in contract testing WordPress REST endpoints, and to security checks in automated WordPress security audits.
What this gate does not cover
- Dynamic blocks. If
save()returnsnulland PHP renders the block, there is no stored markup to invalidate. Test therender_callbackin PHPUnit instead, including the empty state. - Attribute design. The gate tells you a deprecation is missing. It cannot tell you the attribute schema was a poor long-term choice. That is covered in Gutenberg Block Development: A Production Architecture Guide.
- Editor-only behaviour. Toolbar controls, inspector panels and iframe styling need end-to-end tests in a real editor.
Questions people actually ask
- Why do I get “This block contains unexpected or invalid content”?
- The block’s current
save()output is not equivalent to the markup stored in the post, and no deprecation reproduces that stored markup. Add a deprecation describing the previoussave()rather than clicking “Attempt Block Recovery”, which rewrites the block from whatever attributes it could parse and can lose content. - Can I run this before committing?
- Yes. The fixture tests are fast enough for a pre-commit or pre-push hook via Husky or lint-staged, limited to changed block directories. Keep the CI job as the enforced gate, because local hooks can be skipped.
- Does this apply to ACF blocks?
- Mostly not. ACF blocks render in PHP, so there is no client-side
save()markup to validate and they do not produce this error. Theblock.jsonschema check still applies, and their field data deserves its own tests.
Tired of editors hitting invalid-block errors?
I set up block validation gates, fixture suites and deprecation strategies for teams running large custom block libraries.