Why Your Block Deprecation Silently Fails: How the Editor Validates save() Output Against block.json Attributes Before It Ever Migrates Old Markup

You changed the wrapper element in save(). You added a deprecated entry with a migrate() that renames the attribute. You opened an old post. The block still shows the invalid-content warning, and your migrate() never runs. You add a console.log inside it. Nothing. You conclude the deprecation API is broken.

It is not broken. It is gated. The editor does not run your migration because your deprecation’s save() did not reproduce the stored markup, and a deprecation whose save() fails validation is skipped entirely — including its migrate(). The migration logic is downstream of a validation check that most people debug in the wrong order.

Symptom

Old block instances show the invalid-content warning after a save() or attribute change. The deprecated array is populated. The migrate() function is correct in isolation. It does not execute.

If you instrument the deprecation object, you will find that isEligible may run, but migrate does not. That asymmetry is the tell: the entry was considered, its save() was evaluated, and the result did not validate. The entry was discarded before the migration stage.

Trace

The selection loop is documented in the Block Deprecation reference. The relevant sequence, quoted from the WordPress Block Editor handbook:

If the current save method does not produce a valid block the first deprecation in the deprecations array is passed the original saved content. If its save method produces valid content this deprecation is used to parse the block attributes. If it has a migrate method it will also be run using the attributes parsed by the deprecation. If the first deprecation’s save method does not produce a valid block the subsequent deprecations in the array are tried until one producing a valid block is encountered.

And the gate that produces the silent skip:

It is important to note that if a deprecation’s save method does not produce a valid block then it is skipped completely, including its migrate method, even if isEligible would return true for the given attributes.

Two things follow from that. First, the deprecation array is not a migration chain. The documentation is explicit: “Deprecations do not operate as a chain of updates in the way other software data updates, like database migrations, do.” Each entry is evaluated against the original saved content, not against the output of the previous entry. Second, the entry that validates is the only one whose attributes are passed forward. The attributes and inner blocks from the first deprecation that produces valid content are passed back to the current save() to generate new content. Earlier entries are not fed forward.

So the loop is a search, not a pipeline. It looks like this:

stored markup
  │
  ├─ current save() validates? ──> use current block, stop
  │
  └─ invalid
       │
       ├─ deprecation[0].save() validates? ──> parse attrs with deprecation[0].attributes
       │                                        run deprecation[0].migrate() if present
       │                                        pass attrs + innerBlocks to current save()
       │                                        stop
       │
       ├─ deprecation[1].save() validates? ──> same, with deprecation[1]
       │
       └─ ...

If no entry validates, the block remains invalid. The editor does not fall through to the current save() with guessed attributes. It does not run any migrate(). It does not warn you that your save() was the problem.

Root cause

The validation check is not comparing your deprecation’s save() output to the current block’s save() output. It is comparing the serialized result of the deprecation’s save() — using the deprecation’s own attribute definitions — to the original stored markup. The @wordpress/blocks reference states the validity condition plainly: “A block is considered valid if, when serialized with assumed attributes, the content matches the original value.”

That means three things must line up for a deprecation to validate:

  1. The deprecation’s attributes definition must parse the stored markup into the same attribute values the original save() used.
  2. The deprecation’s save() must serialize those attributes back into markup byte-identical to what is stored.
  3. The deprecation’s supports must produce the same wrapper class and attribute output the original block produced.

The documentation is direct about the inheritance trap: “attributes, supports, and save are not automatically inherited from the current version, since they can impact parsing and serialization of a block, so they must be defined on the deprecated object in order to be processed during a migration.” A deprecation that omits supports and relies on the current block’s supports will serialize a different wrapper. The comparison fails. The entry is skipped.

The same applies to sourced attributes. The attributes reference notes: “The block is responsible for using the save function to ensure that all attributes with a source field are saved according to the attributes definition. This is not automatic.” If your deprecation’s attributes declares a source: 'html' selector that no longer matches the stored markup — because the stored markup used a different tag, class, or nesting — the parsed attribute is empty or wrong, the serialized output diverges, and validation fails.

There is a second, quieter failure mode. If a deprecation’s save() imports a helper from the current codebase, and that helper changes, the deprecation’s historical output changes with it. The documentation warns: “if a deprecation’s save method imports additional functions from other files, changes to those files may accidentally change the behavior of the deprecation.” The deprecation was correct when you wrote it. It stopped being correct when someone edited a shared utility. Nothing in the deprecation file changed, so nothing in review flagged it.

Fix

Treat each deprecation entry as a frozen snapshot of a released block definition, not as a patch against the current one.

Reproduce the historical save() exactly

Copy the save() implementation from the released tag that produced the stored markup. Do not rewrite it from memory. Do not simplify it. The comparison is against the original serialized value, so the deprecation’s save() must produce that value, not an equivalent one.

// deprecated/v1/save.js
// Frozen copy of the save() from the release that produced the stored markup.
// Do not refactor. Do not import shared helpers that can change.
export default function save( { attributes } ) {
    return (
        <div className="notice">
            <p>{ attributes.text }</p>
        </div>
    );
}

Define attributes and supports on the deprecation

Do not rely on inheritance. The deprecation object must carry its own attributes and supports definitions, matching the release that produced the stored markup.

// deprecated/v1/index.js
import save from './save';

const attributes = {
    text: {
        type: 'string',
        source: 'html',
        selector: 'p',
    },
};

const supports = {
    className: false,
};

export default {
    attributes,
    supports,
    save,
    migrate( oldAttributes ) {
        return {
            content: oldAttributes.text,
            tone: 'info',
        };
    },
};

If the stored markup was produced with supports.className enabled, the deprecation must declare it. If the stored markup had no wrapper class, the deprecation must declare className: false. The serialized output is compared literally.

Order entries newest first

The documentation recommends reverse chronological order: “The deprecations in the array should be in reverse chronological order. This allows the block editor to attempt to apply the most recent and likely deprecations first, avoiding unnecessary and expensive processing.” The newest historical format is usually the most common in installed content, so it should be attempted first.

import v3 from './deprecated/v3';
import v2 from './deprecated/v2';
import v1 from './deprecated/v1';

export const deprecated = [ v3, v2, v1 ];

Add new migrations to every deprecation that can still match

Because only the first validating entry’s attributes are passed forward, a new migration must be present in every deprecation that can still match installed content. The documentation states this directly: “if you have several deprecations for a block and want to perform a new migration, like moving content to InnerBlocks, you may need to update the migrate methods in multiple deprecations in order for the required changes to be applied to all previous versions of the block.”

If v1 and v2 both validate against content still in the database, and you add a new attribute rename, both v1 and v2 need the rename in their migrate(). Adding it only to v2 leaves v1 content migrated to the v2 shape, not the current shape.

Use isEligible only for valid-but-stale content

The deprecation is tried when the parsed block is invalid, or when isEligible returns true. That second path exists for content that is technically valid but still needs normalization — a renamed enum value, a class that should be removed, an attribute that should be dropped.

isEligible( attributes ) {
    return attributes.alignment === 'centre';
},
migrate( attributes ) {
    return { ...attributes, alignment: 'center' };
},

Keep the predicate narrow. A broad isEligible that returns true for current content will cause the editor to re-run the migration on every load, which is both wasteful and a source of dirty-post state.

Verification

You cannot verify a deprecation by reading it. You verify it by feeding it the exact serialized markup it is supposed to recognize and confirming that the editor accepts it.

Keep fixtures per released format

The documentation recommends this: “It is also recommended to keep fixtures which contain the different versions of the block content to allow you to easily test that new deprecations and migrations are working across all previous versions of the block.” One fixture per public serialized format, captured from a real post created with the released build.

fixtures/
  v1.html
  v2.html
  v3.html

For each fixture, confirm four things:

  1. The editor loads it without an invalid-content warning.
  2. Attributes and inner blocks parse without losing user content.
  3. Saving converts it to the current serialized format.
  4. Reopening the migrated post remains valid.

Inspect the parsed attributes before the migration runs

In the editor console, you can inspect what the parser extracted from a stored block. The @wordpress/blocks package exposes getBlockAttributes, which “returns the block attributes of a registered block node given its type,” and getSaveContent, which “returns the static markup to be saved, given a block type containing a save render implementation and attributes.” You can use these to compare the deprecation’s parsed attributes and serialized output against the stored markup without guessing.

// In the editor console, with the block selected.
const { getBlockAttributes, getSaveContent, getBlockType } = wp.blocks;
const blockType = getBlockType( 'my-plugin/notice' );
const stored = wp.data.select( 'core/block-editor' )
    .getBlock( clientId ).originalContent;

const parsed = getBlockAttributes( blockType, stored );
const reserialized = getSaveContent( blockType, parsed );

console.log( { stored, parsed, reserialized, match: stored === reserialized } );

If match is false, the current block does not validate the stored content, and the deprecation loop will run. If you then swap in the deprecation’s attributes and save and match is still false, that deprecation will be skipped. That is the check to run before you touch migrate().

Check the database for stale markup

Block markup lives in wp_posts.post_content. You can count how many posts still contain the old serialized form before and after a migration pass.

SELECT ID, post_title
FROM wp_posts
WHERE post_status = 'publish'
  AND post_content LIKE '%<!-- wp:my-plugin/notice %'
  AND post_content LIKE '%<p>%'
ORDER BY post_modified DESC
LIMIT 20;

Adjust the LIKE patterns to the distinguishing markup of the old format. This tells you which posts still carry the old shape and whether the editor has rewritten them. It does not tell you whether the deprecation validated — only the editor can tell you that — but it tells you the scope of the problem.

WP-CLI for a content-level check

You can search post content for the old delimiter and attribute shape without opening the editor.

wp post list --post_type=post --post_status=publish \
  --field=ID \
  --meta_key=_does_not_exist \
  --format=ids | xargs -n1 -I{} wp post get {} --field=content \
  | grep -c 'wp:my-plugin/notice'

That count is a rough inventory. It is not a validation result. The validation result comes from the editor, because the editor is the only place where the block type’s save() and the deprecation’s save() are both registered and compared.

What to check before the next save() change

Before merging any change to save() or to a sourced attribute, answer these questions:

  1. Does the new save() regenerate markup identical to the latest released version? If not, is the markup change necessary rather than cosmetic?
  2. Does every historical deprecation still reproduce its own released markup, using its own attributes and supports?
  3. Does every released fixture migrate directly to the current format, without relying on another deprecation having run first?
  4. Does every deprecation that can still match installed content carry the latest migrate() changes?
  5. Does any deprecation import a helper that can change independently of the deprecation file?

If the answer to any of those is no, the deprecation will be skipped at validation time, and the migrate() you wrote will not run. The fix is not in migrate(). It is in the save() and the attribute definitions that gate it.

FAQ

Why does my migrate() never run even though isEligible returns true?

Because the deprecation’s save() did not produce valid content. The documentation states that a deprecation whose save() does not produce a valid block is skipped completely, including its migrate(), even if isEligible would return true. The isEligible check is not a bypass around validation; it is an additional trigger for entries that would otherwise not be tried because the block is already valid.

Can I rely on the current block’s attributes and supports in a deprecation?

No. The documentation is explicit that attributes, supports, and save are not automatically inherited from the current version, because they affect parsing and serialization. Each deprecation must define them.

Do deprecations run in sequence, each building on the previous one?

No. The documentation states that deprecations do not operate as a chain of updates the way database migrations do. Each entry is tried against the original saved content until one produces valid content. Only that entry’s attributes and inner blocks are passed forward to the current save().

What order should the deprecated array be in?

Reverse chronological order, newest first. The documentation recommends this so the editor attempts the most recent and likely deprecations first, avoiding unnecessary processing.

How do I know which deprecation is failing?

You do not get a direct signal from the editor. The practical approach is to reproduce the stored markup against each deprecation’s attributes and save() in isolation, using getBlockAttributes and getSaveContent from @wordpress/blocks, and confirm that the serialized output matches the stored content. The first entry that matches is the one the editor will use. If none match, the block stays invalid.

Is isValidBlockContent still the right function to check?

The @wordpress/blocks reference marks isValidBlockContent as deprecated and says to use validateBlock instead to avoid data loss. The validity condition it describes is the same: a block is valid if, when serialized with assumed attributes, the content matches the original value.

Related

If you are still at the stage where a fresh install shows nothing on the front end, the failure is earlier in the stack than block validation. What to Fix First When a New WordPress Site Says Nothing Found covers the front-end rendering path before you start debugging the editor.

Sources