The 5.8 upgrade went onto a staging copy on the twenty-first of July and the sidebar looked correct. The widget screen did not: six custom widgets had become blocks containing their own rendered output, with the settings that produced it gone.
The symptom
$ wp option get widget_turkerdev_recent_products --format=json
{"2":{"title":"New in","count":4,"category":12},"_multiwidget":1}
# after the upgrade, in the editor:
# [HTML block]
# <div class="widget recent-products"><h3>New in</h3>
# <ul><li><a href="/p/8814">Desk lamp</a></li>...
#
# the markup is frozen. count and category are gone.
# adding a product does not change it.The dynamic widget became static markup, which is a data loss rather than a display problem — the settings that made it dynamic no longer exist anywhere the editor can reach. This did not happen on every widget and the ones it happened to were the ones with a custom form.
Why it happens
A widget is a PHP class with a form method and a widget method; a block is a JavaScript component with attributes. There is no automatic conversion between the two, so the editor renders the widget once and keeps what it produced.
The legacy widget block exists to prevent exactly this and only applies when the widget is recognised as a registered widget at the moment the screen loads. A widget registered on a hook that runs later than the editor expects is not recognised.
The fix
Stopping the damage first
// wp-content/mu-plugins/turkerdev-widgets.php
// opt out of both the screen and the customiser
add_filter( 'use_widgets_block_editor', '__return_false' );
// TODO(2022-06): remove. the filter will not exist forever.
// tracked in ENG-3140. six widgets to convert:
// recent-products, promo-banner, tier-notice,
// stock-alert, category-tree, review-carousel
The opt-out is legitimate and is a decision rather than a reflex, because the deadline belongs to somebody else. Writing the removal date and the ticket number in the file is what stops it becoming permanent — a filter with no expiry is a filter that will be discovered in 2025.
Applying it before upgrading production rather than after is the important part. Once the conversion has happened on a live site the settings are gone, and restoring them means a database restore.
Making the legacy block recognise the widget
// the widget must be registered by the time the editor
// asks — widgets_init, priority 10, not later
add_action( 'widgets_init', function (): void {
register_widget( TurkerDev_Recent_Products::class );
} );
// and hiding it from the picker, so no NEW instances of
// something being deprecated can be created
add_filter( 'widget_types_to_hide_from_legacy_widget_block',
static function ( array $types ): array {
$types[] = 'turkerdev_promo_banner';
return $types;
}
);
Registration timing was the actual cause for four of the six: they were registered on init at priority 20 for reasons dating to 2017, which is after the block editor has assembled its list. Moving them to widgets_init made the legacy block recognise them and the settings survived.
Hiding a widget from the picker while keeping existing instances working is the right shape for a deprecation, and it gives a countable measure of progress — the number of remaining instances is a database query rather than a feeling.
Converting one, properly
{
"apiVersion": 2,
"name": "turkerdev/recent-products",
"title": "Recent products",
"category": "widgets",
"attributes": {
"title": { "type": "string", "default": "New in" },
"count": { "type": "number", "default": 4 },
"category": { "type": "number" }
},
"editorScript": "file:./index.js",
"render": "file:./render.php"
}
A dynamic block with a render callback is the direct equivalent of a widget and is the right target — the markup is produced at request time, so it stays current and needs no deprecation array when it changes. The attributes map one-to-one onto the widget’s settings.
import { useBlockProps, InspectorControls } from '@wordpress/block-editor'
import { PanelBody, RangeControl, TextControl } from '@wordpress/components'
export default function Edit({ attributes, setAttributes }) {
const { title, count } = attributes
return (
<div {...useBlockProps()}>
<InspectorControls>
<PanelBody title="Settings">
<TextControl label="Title" value={title}
onChange={(v) => setAttributes({ title: v })} />
<RangeControl label="Count" value={count} min={1} max={12}
onChange={(v) => setAttributes({ count: v })} />
</PanelBody>
</InspectorControls>
<ServerSideRender block="turkerdev/recent-products"
attributes={attributes} />
</div>
)
}
ServerSideRender is the shortcut that makes a dynamic block affordable: the editor preview is a REST request to render the block server-side, so the markup is written once in PHP rather than twice. It costs a request per keystroke without debouncing and is the right trade for a widget-sized block.
Migrating the settings that exist
// a WP-CLI command, run against a copy first
$instances = get_option( 'widget_turkerdev_recent_products', array() );
foreach ( $instances as $key => $settings ) {
if ( ! is_numeric( $key ) ) {
continue; // _multiwidget
}
WP_CLI::log( sprintf(
'<!-- wp:turkerdev/recent-products {"title":"%s","count":%d} /-->',
esc_attr( $settings['title'] ),
(int) $settings['count'],
) );
}
Generating the block markup from the stored widget settings and having somebody paste it in is a smaller and safer migration than writing something that rewrites the sidebar automatically. Six widgets across four sidebars is twenty minutes of pasting and no risk of a script mangling a live sidebar.
Verifying it worked
$ wp option get widget_block --format=json | jq -r '.[].content'
| grep -c 'wp:turkerdev'
6
$ wp eval 'var_dump( apply_filters("use_widgets_block_editor", true) );'
bool(true) # the opt-out is gone
$ curl -s https://site.example | grep -c 'class="widget recent-products"'
1
# and the assertion that it is dynamic again:
$ wp post create --post_type=product --post_title='New thing' --porcelain
$ curl -s https://site.example | grep -c 'New thing'
1Publishing a product and checking it appears is the test that distinguishes a working dynamic block from the frozen HTML it replaced, and it is the assertion the original failure would have failed. Everything else is structure.
What this costs
A deadline set by somebody else, and a conversion that is a rewrite rather than a port — a PHP form method and a JavaScript inspector panel have nothing in common except the settings they produce. Six widgets took the better part of a week, and the estimate before starting was two days.
Testing an upgrade on a copy of a real site, widget by widget, is the only way to find out what will happen, and doing it before the client sites upgrade themselves is the entire value. The four widgets that broke because of a registration priority set in 2017 would not have been predicted by reading anything.