Variations, Styles, Transforms and Deprecations

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A block type is not frozen once it is registered. A variation inserts the same block with preset attributes and inner blocks under its own name and icon; a block style adds an is-style-* class the author picks from the sidebar; a transform converts one block (or a shortcode, or pasted HTML) into another; and a deprecation teaches the editor to read content that an older version of a static block saved. This page is for plugin and block developers. It adds all four to the lumen-catalog plugin of the Lumen Press site: a "Book" variation of the core Group block, genre variations of lumen/latest-books generated in PHP, a "Compact" style for lumen/book-card, transforms from the [lumen_books] shortcode, and version 0.2.0 of lumen/book-card, whose markup change is shipped with a deprecated entry and a migrate function.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing on this page changes in it so far. The code was built with a real wp-scripts build; the PHP side ran on a fresh 7.1.3 site (Docker Compose stack of Local Development Environments); editor-side results come from @wordpress/blocks 15.27.0 from npm (7.1.3 itself bundles 15.24.0) and @wordpress/block-library run in Node with jsdom against the built files, as in Static and Dynamic Blocks, which also explains block validation and the lumen/book-card 0.1.0 code this page starts from.

Four tools compared

Tool What it changes Where it is stored Typical use

Variation

Initial attributes and inner blocks, plus title, icon and description in the inserter and sidebar

Nothing of its own: the block’s attributes are saved as usual

Presets of one block (Group "Row", Embed "YouTube", a "Book" Group)

Block style

One class, is-style-<name>, on the wrapper

className attribute in the delimiter and the class in the HTML

Alternative looks of the same markup

Transform

Converts blocks, shortcodes, pasted HTML or typed text into a block

Nothing: the result is a new block

Migration paths and authoring shortcuts

Deprecation

How the editor parses content saved by an older save()

Nothing until the post is re-saved

Changing a static block’s markup without breaking posts

Where the code lives

Blocks keep their transforms and deprecations next to their index.js in src/blocks/<name>/. Variations and styles for blocks the plugin does not own (here the core Group block) need a separate editor script: src/editor/index.js, built into build/editor/ and enqueued by src/Editor.php. The build script of package.json (see block.json and Registration) gains a second step:

wp-scripts build --source-path=src/blocks --output-path=build/blocks --blocks-manifest --experimental-modules
wp-scripts build --source-path=src/editor --output-path=build/editor

With no block.json in src/editor/, the second command uses src/editor/index.js as its entry and writes build/editor/index.js plus index.asset.php, whose dependencies are wp-blocks and wp-i18n.

Block variations

What a variation is

A variation is an object attached to a block type. The editor shows it in the inserter (and in other places, per its scope) as if it were a block of its own; inserting it creates the base block with the variation’s attributes and innerBlocks. The fields are:

Field Meaning

name

Unique machine name. Needed to unregister the variation, to target it from a transform (variationName) and for isDefault to behave.

title, description, icon, category, keywords, example

What the inserter, its search and preview, and the sidebar’s block card show.

attributes, innerBlocks

Attribute values and nested blocks ([ name, attributes, innerBlocks ] arrays) applied on insertion.

scope

block (offered by the block’s own placeholder picker, as Columns and Query do), inserter, transform (offered in the sidebar’s variation switcher of an existing block). Default: block and inserter.

isDefault

Replace the base block in the inserter. Only the first registered default wins.

isActive

How the editor recognizes an existing block as this variation: an array of attribute names (nested paths such as layout.type since 6.6) or, in JavaScript only, a function.

The handbook also lists shortcut and innerContent (Custom HTML only); neither is in the editor scripts bundled with 7.1.3, so they need the Gutenberg plugin for now.

A "Book" variation of core/group

Lumen Press editors often lay out a book by hand: cover, title, ISBN and blurb in a Group. A variation turns that into one inserter item, "Book", while the content stays core blocks that survive the plugin’s removal. The editor script also registers the Book Card styles described in Block styles:

import { registerBlockStyle, registerBlockVariation } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';

registerBlockVariation( 'core/group', {
	name: 'lumen-book',
	title: __( 'Book', 'lumen-catalog' ),
	description: __(
		'A cover, title, ISBN and blurb in one group, styled by the theme.',
		'lumen-catalog'
	),
	icon: 'book-alt',
	category: 'lumen-catalog',
	keywords: [ __( 'book', 'lumen-catalog' ), 'isbn' ],
	scope: [ 'inserter' ],
	attributes: {
		className: 'is-lumen-book',
		layout: { type: 'constrained' },
		metadata: { name: __( 'Book', 'lumen-catalog' ) },
	},
	innerBlocks: [
		[ 'core/image', { sizeSlug: 'medium' } ],
		[
			'core/heading',
			{ level: 3, placeholder: __( 'Book title', 'lumen-catalog' ) },
		],
		[
			'core/paragraph',
			{ className: 'lumen-book__isbn', placeholder: 'ISBN' },
		],
		[
			'core/paragraph',
			{ placeholder: __( 'Short blurb', 'lumen-catalog' ) },
		],
	],
	// The class list may also hold a block style or a user class.
	isActive: ( { className = '' } ) =>
		className.split( ' ' ).includes( 'is-lumen-book' ),
} );

registerBlockStyle( 'lumen/book-card', [
	{
		name: 'default',
		label: __( 'Default', 'lumen-catalog' ),
		isDefault: true,
	},
	{ name: 'compact', label: __( 'Compact', 'lumen-catalog' ) },
] );

metadata.name is the List View name an author sets with Rename. The variation ships no CSS: is-lumen-book is a hook for the theme (see Style Variations and Section Styles).

isActive and scope

Without isActive, an inserted Book shows as a plain "Group". The editor walks the variations in registration order: array-form isActive entries compare attributes with exact equality and the match with the most attributes wins (6.6); a function returns the first match. Core’s Row, Stack and Grid check layout.type; Group has no isActive and is the fallback. [ 'className' ] would stop matching once an author added a style or class, hence the function. In Node, getActiveBlockVariation() returned lumen-book for className is-lumen-book is-style-section-dark, and group for a plain constrained group.

The transform scope would add Book to the Group/Row/Stack/Grid switcher in the sidebar, but that switcher applies only attributes, never innerBlocks, so a group would gain the class and nothing else. To remove a variation, call wp.blocks.unregisterBlockVariation( 'core/group', 'group-grid' ) (by name). A new isDefault variation is ignored while an earlier default, such as Group’s "Group", is still registered.

Variations from PHP

Variations that depend on site data, such as one inserter item per genre, are easier to build on the server. Since 6.5, WP_Block_Type::get_variations() calls an optional variation_callback and passes the result through the get_block_type_variations filter, so a plugin can add variations to any block, its own or core’s:

<?php
namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

// Editor-only additions: the editor script and PHP block variations.
final class Editor {

	// Called from Plugin::boot().
	public static function boot(): void {
		add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue' ) );
		add_filter( 'get_block_type_variations', array( self::class, 'genre_variations' ), 10, 2 );
	}

	// Loads build/editor/index.js: the "Book" Group variation and Book Card styles.
	public static function enqueue(): void {
		$asset = include dirname( LUMEN_CATALOG_FILE ) . '/build/editor/index.asset.php';

		wp_enqueue_script(
			'lumen-catalog-editor',
			plugins_url( 'build/editor/index.js', LUMEN_CATALOG_FILE ),
			$asset['dependencies'],
			$asset['version'],
			true
		);
		wp_set_script_translations( 'lumen-catalog-editor', 'lumen-catalog' );
	}

	// Adds a "Latest <genre> books" variation of lumen/latest-books per genre with books.
	public static function genre_variations( array $variations, \WP_Block_Type $block_type ): array {
		if ( 'lumen/latest-books' !== $block_type->name ) {
			return $variations;
		}

		$terms = get_terms(
			array(
				'taxonomy'   => 'lp_genre',
				'hide_empty' => true,
				'number'     => 12,
			)
		);
		if ( is_wp_error( $terms ) ) {
			return $variations;
		}

		foreach ( $terms as $term ) {
			$variations[] = array(
				'name'       => 'genre-' . $term->slug,
				/* translators: %s: genre name. */
				'title'      => sprintf( __( 'Latest %s books', 'lumen-catalog' ), $term->name ),
				'icon'       => 'book',
				'keywords'   => array( $term->name ),
				'attributes' => array( 'genre' => $term->slug ),
				'isActive'   => array( 'genre' ),
				'scope'      => array( 'inserter', 'transform' ),
			);
		}

		return $variations;
	}
}

Plugin::boot() calls Editor::boot() next to Blocks::boot(). The transform scope suits these variations: switching a Latest Books block between genres only changes genre. With three genres, Poetry still without books:

wp eval 'foreach ( WP_Block_Type_Registry::get_instance()->get_registered( "lumen/latest-books" )->get_variations() as $v ) { printf( "%s | %s | %s\n", $v["name"], $v["title"], wp_json_encode( $v["attributes"] ) ); }'
wp eval '$r = rest_do_request( new WP_REST_Request( "GET", "/wp/v2/block-types/lumen/latest-books" ) ); echo wp_json_encode( wp_list_pluck( $r->get_data()["variations"], "name" ) ), "\n";' --user=admin
genre-crime | Latest Crime books | {"genre":"crime"}
genre-fantasy | Latest Fantasy books | {"genre":"fantasy"}
["genre-crime","genre-fantasy"]

The post editor gets the same list without a REST call: get_block_editor_server_block_settings() puts each block type’s variations into the editor’s bootstrap data (two for lumen/latest-books here), where they merge with those registered in JavaScript. The filter runs for every block type on every get_variations() call, so it returns early for other blocks and keeps its query bounded. isActive can only be an array and icon a Dashicon name or SVG string, because the value travels as JSON, and strings need __(). A block the plugin owns can instead point block.json at a PHP file returning the array ("variations": "file:./variations.php", 6.7), loaded lazily through variation_callback.

Block styles

A block style is a name and a label; picking it in the sidebar’s Styles panel adds is-style-<name> to the block’s className, and nothing else, which is the line between a style and a variation. The editor script above registers two for lumen/book-card (an array registers several at once); isDefault: true makes "Default" the style shown as active when no is-style-* class is present, and selecting it removes the class. The CSS goes where both the front end and the iframed editor load it, the block’s style file; 0.2.0 appends these rules to style.scss:

.wp-block-lumen-book-card__cover {
	margin: 0;
}

/* "Compact" block style, registered in src/editor/index.js. */
.wp-block-lumen-book-card.is-style-compact {
	gap: 0.5rem;
}

.wp-block-lumen-book-card.is-style-compact .wp-block-lumen-book-card__isbn {
	display: none;
}

A style can come from registerBlockStyle() in an editor script, as here (PHP’s WP_Block_Styles_Registry never sees it); from "styles" in the block’s own block.json; or from register_block_style() in PHP, usually in a theme, with inline_style, style_handle or (6.6) style_data, which also shows it in Global Styles and allows section styles: see Style Variations and Section Styles. Remove a style on the side that registered it: wp.blocks.unregisterBlockStyle( 'core/quote', 'plain' ) inside wp.domReady() for JavaScript and block.json styles, unregister_block_style() for PHP ones. Content that already has the class keeps it.

Transforms

Types

Transforms are declared under transforms.from and transforms.to of a block; every type accepts priority (default 10, lower wins) for when several match:

Type Direction Applied when

block

from, to

The author picks a block in the toolbar’s block switcher (or a multi-block selection with isMultiBlock). isMatch hides the option; variationName makes it produce a named variation.

shortcode

from

Raw handling: pasting text, or Convert to blocks on a Classic block. tag takes one tag or several; attributes maps shortcode attributes to block attributes, or transform builds the block.

raw

from

Pasting or dropping HTML, and Convert to blocks. Matched by selector or isMatch on each top-level node; schema says which elements and attributes survive the paste cleanup.

enter, prefix, files

from

Typed text matching regExp plus Enter; a prefix plus a space; dropped files.

From [lumen_books] to Latest Books

The [lumen_books genre="..." limit="..."] shortcode of Shortcodes and the dynamic lumen/latest-books block do the same job. Transforms give old content three paths into the block, and one back:

import { createBlock } from '@wordpress/blocks';
import { next } from '@wordpress/shortcode';

// [lumen_books genre="..." limit="..."] -> block attributes.
const fromShortcodeAttributes = ( { genre = '', limit = 5 } ) => ( {
	genre: String( genre ),
	count: Math.min( Math.max( parseInt( limit, 10 ) || 5, 1 ), 12 ),
} );

const transforms = {
	from: [
		{
			// Pasted text and "Convert to blocks" on Classic content.
			type: 'shortcode',
			tag: 'lumen_books',
			transform: ( { named } ) =>
				createBlock(
					'lumen/latest-books',
					fromShortcodeAttributes( named )
				),
		},
		{
			// An existing Shortcode block, from the block switcher.
			type: 'block',
			blocks: [ 'core/shortcode' ],
			isMatch: ( { text } ) =>
				/^\s*\[lumen_books[\s\]]/.test( text || '' ),
			transform: ( { text } ) =>
				createBlock(
					'lumen/latest-books',
					fromShortcodeAttributes(
						next( 'lumen_books', text ).shortcode.attrs.named
					)
				),
		},
		{
			// HTML that [lumen_books] printed, pasted from the front end.
			type: 'raw',
			priority: 5,
			// Paste cleanup strips "class" from lists, so match on data-genre.
			isMatch: ( node ) =>
				node.nodeName === 'UL' && node.hasAttribute( 'data-genre' ),
			schema: {
				ul: {
					attributes: [ 'data-genre' ],
					children: {
						li: {
							children: {
								a: {
									attributes: [ 'href' ],
									children: { '#text': {} },
								},
							},
						},
					},
				},
			},
			transform: ( node ) =>
				createBlock( 'lumen/latest-books', {
					genre: node.dataset.genre || '',
					count: Math.min( node.children.length || 3, 12 ),
				} ),
		},
	],
	to: [
		{
			type: 'block',
			blocks: [ 'core/shortcode' ],
			transform: ( { genre, count } ) =>
				createBlock( 'core/shortcode', {
					text: genre
						? `[lumen_books genre="${ genre }" limit="${ count }"]`
						: `[lumen_books limit="${ count }"]`,
				} ),
		},
	],
};

export default transforms;

The file is src/blocks/latest-books/transforms.js; index.js passes it to registerBlockType() next to edit. @wordpress/shortcode (wp-shortcode in index.asset.php) parses attributes with the same rules as PHP. count is clamped to 1–12, the block’s RangeControl range, where the shortcode allowed 20.

Run in Node against the built block (paste and Convert to blocks go through pasteHandler() and rawHandler(), the block switcher through switchToBlockType()):

paste '[lumen_books genre="fantasy" limit="3"]'         -> lumen/latest-books {"count":3,"genre":"fantasy","showDate":true}
convert '<p>New this month:</p>\n[lumen_books limit=40]\n' -> core/paragraph {"content":"New this month:"}, lumen/latest-books {"count":12,"genre":"","showDate":true}
paste '<ul class="lumen-books" data-genre="crime">' (2 items) -> lumen/latest-books {"count":2,"genre":"crime","showDate":true}
paste '<ul><li>One</li><li>Two</li></ul>'                -> core/list
core/shortcode '[lumen_books genre="poetry"]'            -> lumen/latest-books {"count":5,"genre":"poetry","showDate":true}
lumen/latest-books { genre: "crime", count: 4 }          -> core/shortcode {"text":"[lumen_books genre=\"crime\" limit=\"4\"]"}

Three behaviours shaped the code. A shortcode in Classic content is converted only on its own line or paragraph. A raw transform sees the HTML after the paste cleanup, which keeps only what some block’s schema allows: the List schema has no class on ul, so selector: 'ul.lumen-books' never matched and the List block won, while data-genre, allowed by this schema, survives (an ordinary list stays a List). And the raw handler copies any class left on the node into the new block’s className, one more reason not to allow class.

Deprecations

The 0.2.0 change

Version 0.1.0 of lumen/book-card saved its cover as a bare <img> and had a showIsbn toggle. Version 0.2.0 wraps the cover in a <figure>, always saves the ISBN, and replaces the toggle with the "Compact" style, which hides the ISBN with CSS. In block.json, showIsbn is removed and "version" becomes "0.2.0"; the toggle leaves edit.js. save.js is the 0.1.0 save() shown in deprecated.js below with two changes: the image is wrapped in <figure className="wp-block-lumen-book-card__cover">, and the ISBN condition is isbn && instead of showIsbn && isbn && (edit.js renders the same <figure>).

Every post saved with 0.1.0 is now at risk. Two stored cards, one with a cover and one with the ISBN hidden:

<!-- wp:lumen/book-card {"isbn":"978-1-23456-789-7"} -->
<article class="wp-block-lumen-book-card"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/><h3 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p><!-- wp:paragraph -->
<p>A keeper, a storm and a logbook that does not add up.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->
<!-- wp:lumen/book-card {"isbn":"978-1-23456-790-3","showIsbn":false} -->
<article class="wp-block-lumen-book-card"><h3 class="wp-block-lumen-book-card__title">The Glass Orchard</h3><!-- wp:paragraph -->
<p>Three sisters, one orchard and a winter that will not end.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->

Parsed with the 0.2.0 build and no deprecated entry, both are invalid:

Block validation: Expected tag name `figure`, instead saw `img`.
book-card-v1.html: lumen/book-card INVALID
Block validation: Expected token of type `StartTag` ({ type: 'StartTag', tagName: 'p', … }), instead saw `EndTag` ({ type: 'EndTag', tagName: 'article' }).
book-card-v1-hidden-isbn.html: lumen/book-card INVALID

The second card fails although its markup was untouched: without showIsbn, the new save() prints the hidden ISBN. In the editor, each card would show "Block contains unexpected or invalid content." and an Attempt recovery button.

A deprecation with migrate

src/blocks/book-card/deprecated.js keeps a frozen copy of 0.1.0; index.js imports it and passes it as deprecated to registerBlockType() next to edit and save:

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

// Snapshot of version 0.1.0: attributes, supports and save() exactly as shipped.
const v1 = {
	attributes: {
		bookId: { type: 'integer' },
		title: {
			type: 'rich-text',
			source: 'rich-text',
			selector: '.wp-block-lumen-book-card__title',
			role: 'content',
		},
		isbn: { type: 'string', default: '' },
		coverUrl: {
			type: 'string',
			source: 'attribute',
			selector: 'img',
			attribute: 'src',
		},
		showIsbn: { type: 'boolean', default: true },
	},
	supports: {
		html: false,
		align: [ 'wide' ],
		anchor: true,
		color: { background: true, text: true, link: true },
		spacing: { padding: true, margin: [ 'top', 'bottom' ] },
		typography: { fontSize: true },
	},
	// The hidden-ISBN toggle became the "Compact" block style in 0.2.0.
	migrate( { showIsbn, ...attributes } ) {
		if ( showIsbn !== false ) {
			return attributes;
		}
		const classes = ( attributes.className || '' ).split( ' ' );
		return {
			...attributes,
			className: [ ...classes, 'is-style-compact' ].join( ' ' ).trim(),
		};
	},
	save( { attributes } ) {
		const { title, isbn, coverUrl, showIsbn } = attributes;

		return (
			<article { ...useBlockProps.save() }>
				{ coverUrl && <img src={ coverUrl } alt="" /> }
				<RichText.Content
					tagName="h3"
					className="wp-block-lumen-book-card__title"
					value={ title }
				/>
				{ showIsbn && isbn && (
					<p className="wp-block-lumen-book-card__isbn">{ isbn }</p>
				) }
				<InnerBlocks.Content />
			</article>
		);
	},
};

// Newest first.
export default [ v1 ];

The same two posts parsed with this build are valid, and re-serializing them gives what the editor writes on the next save:

<!-- wp:lumen/book-card {"isbn":"978-1-23456-789-7"} -->
<article class="wp-block-lumen-book-card"><figure class="wp-block-lumen-book-card__cover"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/></figure><h3 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p><!-- wp:paragraph -->
<p>A keeper, a storm and a logbook that does not add up.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->
<!-- wp:lumen/book-card {"isbn":"978-1-23456-790-3","className":"is-style-compact"} -->
<article class="wp-block-lumen-book-card is-style-compact"><h3 class="wp-block-lumen-book-card__title">The Glass Orchard</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-790-3</p><!-- wp:paragraph -->
<p>Three sisters, one orchard and a winter that will not end.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->

The validator logged Updated Block: lumen/book-card for each. The hidden ISBN is now a class: the Glass Orchard card saves its ISBN and the Compact style hides it, so the page looks as before.

How the editor applies deprecations

  • Deprecations are tried only when the current save() does not reproduce the stored HTML (or when an entry’s isEligible( attributes, innerBlocks, { blockNode, block } ) returns true for a block that is valid).

  • The entries are tried in array order, and the first whose save() reproduces the stored HTML wins; its attributes (and its migrate, if any) produce the new block. They are not a migration chain: content from 0.1.0 is not passed through a 0.2.0 deprecation on its way to 0.3.0. When a later version needs the same migration, every older entry carries it. Keep the array newest first, so the likely match is tried first.

  • attributes, supports and save are not inherited. supports adds attributes and wrapper classes (className, colours, anchor). Snapshot them as literals rather than importing block.json or shared helpers, which change with the next release; passing metadata.attributes is safe only when they did not change.

  • migrate( attributes, innerBlocks ) returns new attributes, or [ attributes, innerBlocks ] to move content into or out of inner blocks. It runs only when that entry’s save() matched.

  • An entry whose save() fails is skipped entirely, migrate and isEligible included. A 0.1.0 card without cover and with the ISBN shown is valid under 0.2.0 already, so no deprecation runs and the dropped showIsbn (default true) is simply not written back.

Keep one stored-markup fixture per released version, as above, and parse them all in a test. Dynamic blocks avoid the problem entirely (Static and Dynamic Blocks).

What the front end shows

Deprecations live in the editor. Until a post is opened and saved, the server keeps printing the stored 0.1.0 HTML: on the 7.1.3 site, apply_filters( 'the_content', ... ) returned the bare <img> and the card without ISBN. After storing the migrated markup (wp post update <id> migrated.html, standing in for a save in the editor), the same page printed the <figure> and is-style-compact versions, and the front-end response carried the Compact rules in lumen-book-card-style-inline-css (WordPress inlines small block stylesheets). A release’s CSS therefore has to style both markups while un-migrated posts exist; here .wp-block-lumen-book-card img still sizes the bare <img>. Core has no server-side migration of static block HTML: a bulk update would parse, rewrite and save each post itself.

Versions

Version Change

5.0

Block editor with transforms, deprecated (with migrate) and registerBlockStyle().

5.3

register_block_style() and unregister_block_style() in PHP.

5.4

Block Variations API: registerBlockVariation(), unregisterBlockVariation().

5.9

variations field in block.json.

6.5

variation_callback and the get_block_type_variations filter; WP_Block_Type::get_variations().

6.6

Nested attribute paths in array-form isActive and the most-specific-match rule; style_data for PHP block styles.

6.7

"variations": "file:./variations.php" in block.json.

7.1

Post editor always iframed: style CSS must come from the block’s style or enqueue_block_assets, scripts that register variations and styles stay on enqueue_block_editor_assets.

Errors and pitfalls

Symptom Cause and fix

An inserted variation shows the base block’s title and icon in the sidebar

No isActive, or an array form that compares a value the author changed (a full className). Match on what identifies the variation.

The variation appears in the sidebar switcher but adds an empty group

transform scope applies attributes only. Use inserter for variations defined by their inner blocks.

The editor slows down after adding a get_block_type_variations filter

The filter runs for every block type and every call. Check $block_type->name first and keep queries bounded and cached.

unregisterBlockStyle() has no effect

It ran before the style was registered. Depend on the registering script and wrap the call in wp.domReady(); PHP-registered styles need unregister_block_style().

A style looks right on the front end but not in the editor

The CSS was enqueued on wp_enqueue_scripts or admin_enqueue_scripts. Put it in the block’s style or load it with enqueue_block_assets.

A raw transform never matches pasted HTML

The paste cleanup removed what selector/isMatch relies on. Declare the elements and attributes in schema, and lower priority below the core block that would otherwise win.

A shortcode is not converted by Convert to blocks

It is inline in a paragraph. Only shortcodes on their own line become blocks.

"Block contains unexpected or invalid content." after a release

save() changed without a deprecated entry, or the entry lacks the old supports/attributes, or its save() imports code that has since changed.

What older books and tutorials get wrong

Older advice Current practice

The general WordPress books present block styles and patterns only as author features and teach layouts through page builders or shortcodes (Pro WordPress covers shortcodes in classic themes only).

Plugins add their own variations and styles, and give existing shortcodes a shortcode transform so old content converts to blocks.

Tutorials register variations or styles in an inline ES5 script using wp.blocks globals with no declared dependencies.

Build the script with @wordpress/scripts and enqueue it with the dependencies and version from index.asset.php.

"Changing save() is fine; authors can click the recovery button."

Ship a deprecated entry with every markup change and keep fixtures of each released version’s markup.

Deprecations run in sequence like database migrations.

The first entry whose save() matches is used; each entry needs whatever migration it must perform.

Copying attributes from the current block into the deprecation.

Freeze the old attributes, supports and save() as they shipped.