Skip to content
System nominalUTC --:--:--Articles 071

Blocks That Break After an Update, and Why save() Is the Reason

A save() function whose output changes without a matching deprecated entry makes the editor call the block invalid. Here is the reproduction and the fix.

Devon Parker · @dparker

APIs and integrations

Topic
WP
Section
WordPress Internals
Published
16 OCT 2026
Reading time
8 MIN
Words
1937W
Technical cover graphic for Blocks That Break After an Update, and Why save() Is the Reason
Generated schematic — WP series, REV 2.6.

The client opens a page they have not touched in four months, and every block they own is wrapped in a red warning. The heading of the message is a single sentence: Block contains unexpected or invalid content. Underneath it sits a button marked Attempt recovery. They did not update WordPress. They did not edit the page. They updated your plugin, and your plugin changed a save() function.

What follows is the mechanism, the exact reproduction, and the shape of a deprecated entry that repairs posts written by the old version. Everything here was run against WordPress 7.0.7 with the block validation code that ships in @wordpress/blocks 16.2.0. The editor messages are quoted from that install rather than from memory.

Save functions are a serialisation format, not a renderer

A static block stores its markup in the post content between an opening comment delimiter and a closing one. That stored markup is the block's data. The attributes are derived from it on load, and the save() function is the thing that derived them in the first place. Change save() and you have changed the format, which means posts written under the previous format are now an older generation of files.

The editor enforces the contract on every block it cannot render from code. It calls the current save() function with the parsed attributes, compares the generated markup against the stored markup, and records the result as a boolean. When the two differ, the block carries isValid: false and a list of validation issues. The editor then replaces the rendering of that block with a warning component, and the attributes sit in a state where editing them cannot write anything back.

Three consequences follow, and together they explain almost every "blocks broke after the update" ticket.

Validation does not happen on the front end. the_content prints the stored markup. The page looks perfect in an incognito window while the editor considers every block broken. The first person to notice is whoever opens the post, and by then the update has been deployed for a week.

Attributes still parse. The parse step is lenient. It reads the attributes out of whatever markup is there, so an invalid block has correct values and a broken contract at the same time. That is why recovery usually works and why the failure is so confusing.

The comparison is on markup, not on appearance. Two outputs that render identically are still a failed validation if their tags or attributes differ. A wrapper element changed from p to div, or a style class renamed, is enough. Whitespace is not, because the comparison normalises it.

A block that breaks when save() changes

Register a small block. It stores a price as text inside a span, and the outer element carries a class the theme styles.

registerBlockType( 'wpsifero/price-tag', {
    apiVersion: 3,
    title: 'Price tag',
    category: 'widgets',
    attributes: {
        price: { type: 'string', source: 'text', selector: '.price' },
    },
    save: ( { attributes } ) => (
        <p className="price-tag">
            <span className="price">{ attributes.price }</span>
        </p>
    ),
} );

A post is written with that version, and the database ends up holding this.

<!-- wp:wpsifero/price-tag -->
<p class="price-tag"><span class="price">€49</span></p>
<!-- /wp:wpsifero/price-tag -->

Now change save() so the outer class reflects a redesign. Nothing else moves. The attributes are identical, the price is in the same place, and the new output renders the same figure in the same font on any theme that styles .price.

save: ( { attributes } ) => (
    <p className="price-tag price-tag--v2">
        <span className="price">{ attributes.price }</span>
    </p>
)

Load the post that holds the old markup and the editor refuses the block. Running the parser and validator over that exact content, outside a browser, produces the same decision the editor makes:

Block validation: Expected attribute `class` of value `price-tag price-tag--v2`, saw `price-tag`.
Block validation: Block validation failed for `wpsifero/price-tag`.

Content generated by `save` function:

<p class="price-tag price-tag--v2"><span class="price">€49</span></p>

Content retrieved from post body:

<p class="price-tag"><span class="price">€49</span></p>

The parser returns the block with isValid: false and the attributes recovered correctly as {"price":"€49"}. Both halves of that sentence matter. The content survived, and the contract did not.

What "Block contains unexpected or invalid content" actually is

The sentence is emitted by the block wrapper in the editor when a parsed block comes back invalid. It is not a PHP message, it is not in the error log, and it does not appear on the front end. If you grep for it, you find it in the built JavaScript of the editor, next to the buttons that accompany it.

Three actions are offered, and they do different things.

  1. Attempt recovery creates a new block from the parsed attributes and the current save() function, and swaps it in. In the example above the recovered block is a valid price tag with the --v2 class and the €49 intact, because the attribute parsed cleanly out of the old markup.
  2. Resolve opens a comparison dialog that shows the stored content and the generated content side by side.
  3. Convert to HTML and Convert to Classic Block keep the old markup verbatim and drop it into a block that does not participate in validation. It is honest and it is a dead end for anyone who wants to keep editing the block.

Recovery is not automatic

The editor does not rewrite posts on its own. Until somebody opens each post and resolves each block, the stored markup stays as it was. A plugin author shipping the change is choosing to make that work somebody else's problem, which is why the deprecation path exists.

There is also a related warning worth recognising: This block has encountered an error and cannot be previewed. That one comes from edit() throwing, not from a save() mismatch, and the debugging path is different. Keep the two apart.

A deprecated entry is a frozen copy of the old save() function

The fix is to tell the editor what the previous format looked like. A deprecated array holds one entry per historical version, and each entry carries the old save() function plus enough information to match it against stored content.

registerBlockType( 'wpsifero/price-tag', {
    apiVersion: 3,
    attributes: {
        price: { type: 'string', source: 'text', selector: '.price' },
    },
    // Current version. The class name changed.
    save: ( { attributes } ) => (
        <p className="price-tag price-tag--v2">
            <span className="price">{ attributes.price }</span>
        </p>
    ),
    deprecated: [
        {
            // The version that wrote the posts that already exist.
            attributes: {
                price: { type: 'string', source: 'text', selector: '.price' },
            },
            save: ( { attributes } ) => (
                <p className="price-tag">
                    <span className="price">{ attributes.price }</span>
                </p>
            ),
        },
    ],
} );

The mechanics are worth knowing because they explain the compatibility rule. When a parsed block fails validation against the current version, the editor walks the deprecated entries from first to last. For each one it builds a temporary block type by replacing the current save() with the entry's save(), parses the attributes again with the entry's attribute definitions, and validates the result against the stored markup. The first entry that matches marks the block valid and becomes the migration source.

Two details follow from the order of that walk.

Order matters when more than one entry could match. Put the newest old version first. An entry that also matches an even older format will win if it comes earlier in the array, and the attributes will be migrated along the wrong path.

isEligible is an optimisation, not a requirement. When you supply it, the editor skips an entry unless the stored attributes satisfy the matcher. When you do not, every entry is tried. The absence of isEligible is a performance question, not the reason your deprecation stopped working.

If the shape of the attributes changed rather than just the markup, add migrate to the entry. It receives the attributes parsed by the old definition and the inner blocks, and returns the attributes in the current shape. Up to three values can be returned: attributes, inner blocks, and a third element used when a deprecation needs to replace inner block content.

What the deprecated entry does to the post on load

With the entry in place, the same stored markup parses cleanly. The console stops complaining and the block is valid:

=== (A) save() changed, no deprecated entry ===
isValid: false  attributes recovered: {"price":"€49"}

=== (B) same save(), deprecated entry added ===
Updated Block: wpsifero/price-tag
  Block successfully updated for `wpsifero/price-tag`.
isValid: true  attributes: {"price":"€49"}

The second line of the valid case is a message the editor logs to the console through console.info, grouped under Updated Block: %s. It means the deprecated entry matched and the block was migrated in memory. The markup the editor holds afterwards is the current output, and that is what gets written to the post the next time somebody saves it.

That last sentence is the part people miss. The migration is not a database migration. Loading a post with a matching deprecated entry does not rewrite the post. The stored markup keeps the old class until a save happens, and until then every editor load repeats the same match and the same in-memory migration. On a large site that is free, and it is also a reason to open and re-save the pages that matter rather than assuming the fix has taken effect everywhere.

Version constraint

The validation and migration behaviour described here was confirmed on WordPress 7.0.7 with @wordpress/blocks 16.2.0. The deprecated key has existed since the block API stabilised, but the internals of the validator have changed. Re-check the current @wordpress/blocks source before relying on a detail such as the order in which entries are examined.

An invalid block can still swallow an edit

Here is the failure that costs real content. A user opens a post, sees the warning, does not press Attempt recovery, edits the heading inside the invalid block, and saves. The block is still invalid when the post is saved, so the editor writes the stored markup back exactly as it was. The heading edit exists in the in-memory attributes and nowhere else. The user believes the page is now correct, and the page contains the old text.

Nothing warns about this at save time. The warning is visible only on the block, one screen up, and only while the post is open. I have watched this happen to a client's pricing page three days after a plugin release, with the wrong prices live the whole time.

What the advice here does not solve is third-party blocks. If the change landed in a plugin you do not maintain, you cannot add a deprecated entry to it. Your options are to pin the old version, to report the omission, or to resolve each affected post by hand and accept that new posts from other authors will keep producing the old markup until the plugin ships its own entry. There is no filter that retrofits a deprecation from the outside.

Do this before the release, not after

Treat a change to save() the way you would treat a change to a database column. On the branch that makes the change, open a post written by the previous version and confirm the old markup still parses. If isValid is false, add the deprecated entry before the branch merges, with the old save() copied verbatim rather than rewritten from memory.

Grep the plugin for save: and for anything that builds markup from attributes, because the second one is where the change usually hides. Then check the front end last. A page that renders correctly tells you nothing about validation, and that is the entire reason this failure reaches production.

Devon Parker · @dparker

APIs and integrations

Works through REST contract design, webhook reliability, idempotency keys, retry policy and the unglamorous edge cases that appear when two systems disagree about what happened.

Continue

Related in WordPress Internals