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.
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.