Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 08 / 18 35 MIN

Building blocks

Attributes, edit and save

Attribute sources decide where a value lives, save decides what is written, and changing save without a deprecation invalidates every post that uses the block.

The block works. You improve the markup it saves, one wrapper element instead of two, and every post that already contains the block opens with a warning. The content is still there, sitting under a message that says the block contains unexpected or invalid content, and the client wants to know whether the site is broken.

Nothing is broken. The editor is refusing to touch markup it can no longer produce, and that refusal is deliberate, because the alternative is silently rewriting content it does not understand. Clicking through the warning is not the fix either. The fix is to tell the editor how to read the old markup, which is what a deprecation is.

An attribute has a value and a place where that value lives

The type in an attribute definition says what the value is. A separate key, source, says where it is stored and therefore how it is read back. Leave source out and the value is written into the block's comment delimiter as JSON. That is the default, and it is the right choice for anything that is not visible in the markup.

Name a source and the value lives in the HTML instead. attribute reads an HTML attribute, text reads text content, and html reads inner markup and preserves tags inside it. Each one usually pairs with a selector, which is any CSS selector the browser can run; omit the selector and the source reads from the block's own root element. The complete set of allowed values is attribute, text, rich-text, html, raw, query and meta. The first three are the ones to learn first: rich-text is what core blocks use for content edited in place, and query is how a single attribute becomes an array of them.

{
  "attributes": {
    "align":   { "type": "string" },
    "url":     { "type": "string", "source": "attribute", "selector": "a", "attribute": "href" },
    "caption": { "type": "string", "source": "html", "selector": "figcaption" },
    "runs":    { "type": "number", "source": "text", "selector": ".runs" },
    "note":    { "type": "string", "default": "none" }
  }
}

A sourced attribute must be written by save

The attribute system does not put a sourced value into the markup. It only reads one back out. If save renders no a element, the url attribute has nowhere to live, and the value is dropped the next time the block is parsed. The manifest and the save function are two halves of one contract, and nothing checks that they match.

Only the comment delimiter reaches PHP

The editor resolves attribute sources on the client, against the DOM of the stored markup. PHP does not do that. parse_blocks() is a delimiter parser: it reads the JSON in the comment and hands back whatever objects are in it. Run it over a block whose attributes all have sources and the result is an array of the delimiter values and nothing else.

$blocks = parse_blocks( $markup );
// {"align":"wide","note":"set in the delimiter"}
print json_encode( $blocks[0]['attrs'] );

For a dynamic block the attributes that reach render.php are resolved later, by WP_Block_Type::prepare_attributes_for_render(). That method validates each value against the attribute's schema, drops the ones that fail and have no default, and fills in defaults for the ones that are missing. It never reads the HTML. So a dynamic block cannot see a url that lives in an href, no matter how the attribute is declared, and this is the single most common surprise for people who write their first server-rendered block.

The validation is also looser than the type names suggest. Passing the string "3" to an attribute declared as number, or "true" to a boolean, is accepted and left as the string it was. Anything that needs a real number or a real boolean has to be converted in your own code, or guaranteed by the editor. And a value placed in the delimiter for a sourced attribute does survive, which is worth knowing when you are debugging a block whose markup and attributes disagree.

save has to be a pure function of the attributes

The block editor handbook states the rule directly: save should depend only on the attributes it is given, and it must not use useState, useEffect, or reach into a data store. The reason is not style. The editor later re-runs save with the parsed attributes and compares the result, character by character, with the markup stored in the post. If the two differ, the block is invalid.

That comparison is what makes a random value fatal. A timestamp, a counter, a Math.random() call or the current post ID inside save produces one markup on the day the post was written and a different one on every day after. The stored content is fine and the block can never be valid again.

useBlockProps is the other half of a correct pair. In edit it returns the attributes the editor needs to track the block, and in save the useBlockProps.save() form returns the attributes that belong in the markup, including the wp-block-your-name class that the style engine and theme CSS both expect. Add a class you want in the markup through the hook rather than by hand, or save and the editor will disagree about the wrapper.

import { useBlockProps } from '@wordpress/block-editor';

registerBlockType( 'mytheme/note', {
    edit: () => {
        const blockProps = useBlockProps();
        return <p { ...blockProps }>Draft</p>;
    },
    save: ( { attributes } ) => {
        const blockProps = useBlockProps.save();
        return <p { ...blockProps }>{ attributes.text }</p>;
    },
} );

The classic version 1 mistake is a block that returns a bare element from edit without the hook. It renders in the editor, and then selection, duplication and the block toolbar behave strangely because the editor has no wrapper to attach them to. API version 2 made the hook the documented requirement, which is one more reason not to leave a block at version 1.

What the editor actually shows you

When validation fails, the block is rendered with a warning instead of its content, and the message is Block contains unexpected or invalid content. The primary action is Attempt recovery, which keeps the parsed attributes and lets the current save rewrite the markup. Secondary actions are Resolve, which opens a side-by-side comparison of the current content and the result of converting to blocks, and Convert to HTML, which replaces the block with a core HTML block containing the original markup.

The two ways to make the warning go away are not equivalent. Attempt recovery trusts the current save and throws away whatever the old markup held that the new code does not know about. Convert to HTML keeps every byte and gives up the block entirely, which is the honest choice when you do not understand the difference between the two. Neither of them is a migration.

Diagnosing which attribute broke is easier if you know what the validator is doing. It parses the stored markup, then renders the expected output from the current save, then compares the two DOM trees and logs the first difference it finds in a form like an expected attribute value against the value actually present. Open the browser console and the log line names the tag and the divergence. An extra wrapper element shows up as an unexpected tag; a changed class name shows up as an attribute mismatch.

deprecated is the migration path, and it is ordered

A deprecation is an object with the block configuration as it was: its attributes, its supports, its save. Put them in deprecated in reverse chronological order, newest first. When a stored block does not validate against the current version, the editor walks that array, parses the old markup with each candidate, and asks whether the candidate's save reproduces the stored markup. The first one that does is used, its attributes are parsed, and after any migrate function runs the current save writes the new markup.

registerBlockType( 'mytheme/note', {
    attributes: { text: { type: 'string' } },
    save: ( { attributes } ) => {
        const blockProps = useBlockProps.save();
        return <div { ...blockProps }><p>{ attributes.text }</p></div>;
    },
    deprecated: [
        {
            attributes: { text: { type: 'string' } },
            supports: { anchor: true },
            save: ( { attributes } ) => {
                const blockProps = useBlockProps.save();
                return <p { ...blockProps }>{ attributes.text }</p>;
            },
        },
    ],
} );

Note what is repeated in that object. attributes, supports and save are not inherited from the current registration, because all three affect how the old markup is parsed and serialised, and inheriting them would defeat the purpose. Omitting supports from the deprecation means the old shape is checked without the attributes that support added, and the match fails.

A deprecation can also declare isEligible, which is a function returning true when a block that is already valid should still be migrated. The usual reason is a change that the validator cannot see, such as moving content into inner blocks while the outer markup stays identical. isEligible receives the raw parsed attributes, the inner blocks, and an object with the parsed block node and the block being built, so it has enough context to be specific rather than permissive.

One more property of the loop matters when you have several deprecations. A deprecation whose save does not reproduce the stored markup is skipped entirely, including its migrate function, even if isEligible returned true. If you add a new migration for a change that affects several historical shapes, you may need to update more than the most recent deprecation.

Lab 40 minutes

Build a static block with four attributes: one with "source": "attribute" pointing at an href, one with "source": "text" pointing at a class, one with "source": "html" pointing at a figcaption, and one with no source at all. Write a save that renders all three sourced ones.

Insert the block in a post, save, and then read the post content out of the database. Identify which attribute values are in the comment delimiter and which are in the HTML, and check that the unsourced one is present exactly once.

Now change save so the href moves from an a element to a button with a data-url attribute, without changing anything else. Reload the post and write down the message, the actions offered, and what the console says.

Finally, add a deprecation that describes the old shape and confirm the post loads without a warning and that the value that used to be in the href is now in the data-url.

Show the solution and the reasoning

The delimiter holds the unsourced attribute and nothing else, which is the whole point of a source. In a real save the comment looks like this, with the sourced values living in the elements save rendered:

<!-- wp:mytheme/note {"note":"kept in the delimiter"} -->
<figure class="wp-block-mytheme-note">
  <a href="https://example.test">Read</a>
  <figcaption>A <em>caption</em></figcaption>
  <span class="runs">42</span>
</figure>
<!-- /wp:mytheme/note -->

If the unsourced attribute is missing from the post content then save never rendered it, and that is the same class of bug as the sourced ones. An attribute only reaches the delimiter when it is set in the editor and the block has a save that serialises the block at all: a block with no save is treated as dynamic and stores no markup.

After changing the markup, the editor reports Block contains unexpected or invalid content. with Attempt recovery as the primary action and Resolve and Convert to HTML behind the secondary menu. The console log names the divergence, and with only the element changed it points at the tag that was expected against the tag that was found. Recovery works here, and it works by discarding the old a element and writing the new markup from the parsed attributes, which is why it is not a migration: an attribute that existed only in the old element would be lost with no warning.

The deprecation is the wrapper change and nothing more:

import { RawHTML } from '@wordpress/element';

deprecated: [
    {
        attributes: {
            url:     { type: 'string', source: 'attribute', selector: 'a', attribute: 'href' },
            caption: { type: 'string', source: 'html', selector: 'figcaption' },
            runs:    { type: 'number', source: 'text', selector: '.runs' },
            note:    { type: 'string' },
        },
        supports: { anchor: true },
        save: ( { attributes } ) => {
            const blockProps = useBlockProps.save();
            return (
                <figure { ...blockProps }>
                    <a href={ attributes.url }>Read</a>
                    <figcaption><RawHTML>{ attributes.caption }</RawHTML></figcaption>
                    <span className="runs">{ attributes.runs }</span>
                </figure>
            );
        },
    },
],

The deprecation lists the attribute definitions as the old version had them. In this case they are unchanged, and they still have to be written out, because the loop builds a temporary block type from the deprecation entry. The same applies to supports: leaving it out changes the generated wrapper attributes and the comparison fails, which is a confusing way to learn that neither key is inherited.

Two limits are worth stating. A deprecation only helps blocks whose old markup the editor can still parse; if a previous version of the block stored content with a selector that no longer matches anything, the attributes come back empty and no migration can invent them. And the whole mechanism lives in JavaScript that has to ship, so a deprecation array grows forever, one entry per public shape of the block. That is the price of never breaking a post, and it is cheaper than the alternative.

Progress

Mark this lesson done and it stays marked in this browser.