The editor re-runs save() on load and compares it with what is stored, so any change to the markup invalidates every post already containing the block.
registerBlockType('turkerdev/notice', {
save({ attributes }) { /* the current markup */ },
deprecated: [
{
attributes: { /* the OLD attribute shape */ },
save({ attributes }) { /* the OLD markup */ },
migrate(attributes) {
return { ...attributes, tone: attributes.type || 'info' };
},
},
],
});
migrate is what lets attributes be renamed as well as markup changed, and it runs when the block is loaded — the post is only rewritten when the editor saves. Deprecations are tried newest first and the array grows forever, which is the honest cost: a block on its fourth revision carries four save functions. Adding the deprecation in the same commit as the change is the only version that works, because retrofitting means every post has already shown a warning.