The editor changed, and so did every plugin

WordPress 5.0 shipped on 6 December with a new editor, and the Classic Editor plugin shipped alongside it with a support commitment to the end of 2021 — which is an unusual thing for a project to do and reads as an accurate assessment of how disruptive everyone expected this to be. Six client sites and eleven custom plugins had to be assessed in three weeks.

The symptom

# staging, immediately after the upgrade
$ wp plugin list --status=active --format=csv | wc -l
12

# and the editing screen for a product:
#   - the title and content render
#   - the "Shipping" panel is gone
#   - the "SEO" panel renders, at the bottom, unstyled
#   - the "Related products" picker is present and does nothing
#   - console: Uncaught TypeError: jQuery(...).sortable is not a function

Three different failures in one screen. The missing panel had registered itself on an admin hook that no longer fires there; the unstyled one worked through the compatibility layer; the dead one depended on the DOM around the classic editor, which does not exist.

Why it happens

The editor is now a React application that talks to the REST API, and the page the meta box lived on is gone. A meta box is a PHP function that echoes markup into a form and reads $_POST on save, and both halves of that assume a page that submits a form — which the new editor does not do.

Core ships a compatibility layer that renders most meta boxes in a panel below the editor, which is why some of them work. What it cannot do is make anything that manipulated the surrounding DOM work, because the surrounding DOM is a different application.

The fix

Triage: what still works, what needs a flag

add_meta_box(
    'turkerdev_shipping',
    __( 'Shipping', 'turkerdev' ),
    'turkerdev_render_shipping_box',
    'product',
    'side',
    'default',
    array(
        '__block_editor_compatible_meta_box' => true,  // it works. keep it.
        '__back_compat_meta_box'             => false, // not superseded yet
    )
);

The two flags say different things and both should be set deliberately. The first is a claim that the box functions in the new editor, which stops core printing a warning; the second marks a box that has been replaced by a block and should only appear in the classic editor. Declaring nothing means core guesses, and core’s guess is to warn.

A box that reads $_POST in a save hook is generally fine. One that binds a jQuery UI widget to an element outside its own markup is generally not, and no flag will help it — that is the rewrite.

register_meta, and the meta box you can stop writing

For a scalar field the new answer is not a block at all. A registered meta field is editable from the editor sidebar with no form, no nonce and no save handler.

register_post_meta( 'product', 'shipping_class', array(
    'type'              => 'string',
    'single'            => true,
    'show_in_rest'      => true,
    'sanitize_callback' => 'sanitize_text_field',
    'auth_callback'     => function () {
        return current_user_can( 'edit_products' );
    },
) );

// and the post type must support it, which is a separate flag
register_post_type( 'product', array(
    'show_in_rest' => true,
    'supports'     => array( 'title', 'editor', 'custom-fields' ),
) );

The custom-fields entry is required for the meta to be writable through the API and is easy to miss, because its absence produces a field that reads correctly and silently fails to save. The auth_callback is the other one that must not be skipped: the default allows anyone who can edit the post, which is usually right and is worth stating for anything sensitive.

The first custom block, and the build step in a plugin

const { registerBlockType } = wp.blocks;
const { RichText } = wp.editor;
const { __ } = wp.i18n;

registerBlockType('turkerdev/notice', {
  title: __('Notice', 'turkerdev'),
  icon: 'warning',
  category: 'common',
  attributes: {
    tone: { type: 'string', default: 'info' },
    body: { type: 'string', source: 'html', selector: 'p' },
  },

  edit({ attributes, setAttributes }) {
    return (
      <RichText
        tagName="p"
        value={attributes.body}
        onChange={(body) => setAttributes({ body })}
      />
    );
  },

  save({ attributes }) {
    return <RichText.Content tagName="p" value={attributes.body} />;
  },
});

JSX means a build step, which means a plugin that previously contained only PHP now has package.json, a webpack configuration and a build/ directory. That is the single largest practical change for anyone maintaining plugins, and it is not optional for anything with a custom block.

function turkerdev_register_notice_block() {
    wp_register_script(
        'turkerdev-notice',
        plugins_url( 'build/notice.js', __FILE__ ),
        array( 'wp-blocks', 'wp-element', 'wp-editor', 'wp-i18n' ),
        filemtime( plugin_dir_path( __FILE__ ) . 'build/notice.js' )
    );

    register_block_type( 'turkerdev/notice', array(
        'editor_script' => 'turkerdev-notice',
    ) );
}
add_action( 'init', 'turkerdev_register_notice_block' );

The dependency array is what pulls the editor packages in as webpack externals rather than bundling a second copy of React, and getting it wrong is the most common reason a block plugin adds four hundred kilobytes to the admin. Registering on init rather than an admin hook matters because a dynamic block’s render callback has to exist on the front end too.

Serialisation: why the markup is in the post

<!-- wp:turkerdev/notice {"tone":"warning"} -->
<div class="wp-block-turkerdev-notice is-warning">
  <p>Back up before running this.</p>
</div>
<!-- /wp:turkerdev/notice -->

A block is stored in post_content as an HTML comment wrapping real markup, with its attributes as JSON inside the comment. Attributes declared with a source — like body above — are parsed back out of the markup rather than duplicated in the JSON, which keeps it small and means editing the HTML by hand works.

The design decision worth appreciating is that the content survives without the plugin. Deactivate it and the post still shows the text, degraded but present, which was never true of a shortcode. The cost is the block validation error: if save ever produces different markup from what is stored, the editor cannot reconcile them and offers to convert the block to HTML.

// changing save() output breaks every existing post.
// the mechanism for changing it safely:
registerBlockType('turkerdev/notice', {
  // ... current save()

  deprecated: [
    {
      attributes: { /* the old shape */ },
      save({ attributes }) { /* the old markup */ },
    },
  ],
});

The deprecated array is how a block’s output evolves without invalidating content, and it has to be added in the same release that changes save — not afterwards. Forgetting it means every post containing the block shows a validation warning to the next editor who opens it, and “attempt block recovery” rewrites the stored markup one post at a time.

The REST API is now load-bearing

Hardening advice from 2017 frequently included disabling the REST API for unauthenticated requests. On 5.0 that breaks the editor completely, and the failure is a white screen with a console error.

// what a lot of sites did — now breaks editing
// add_filter( 'rest_authentication_errors', '__return_wp_error' );

// what to do instead: remove the endpoints that leak
add_filter( 'rest_endpoints', function ( $endpoints ) {
    if ( ! is_user_logged_in() ) {
        unset( $endpoints['/wp/v2/users'] );
        unset( $endpoints['/wp/v2/users/(?P<id>[d]+)'] );
    }

    return $endpoints;
} );

The users endpoint is what those snippets were aimed at, since it enumerates author slugs for anyone who asks. Removing it specifically keeps the editor working and closes the hole. Checking for a blanket block before upgrading is worth five minutes, because afterwards it presents as “the new editor is broken” rather than as a configuration problem.

Classic Editor as a decision with a date

$ wp plugin install classic-editor --activate

# per post type, which is more precise than the plugin's setting
# and is the right tool for a staged migration:
add_filter( 'use_block_editor_for_post_type', function ( $use, $type ) {
    return 'product' === $type ? false : $use;
}, 10, 2 );

The core filter lets one post type stay behind while the rest move, which is what makes an incremental migration possible. Four of the six sites went that way: pages and posts on the new editor immediately, the heavily customised product type deferred with a ticket and a date.

Treating the plugin as permanent is the mistake. Every month it is installed is a month the site’s custom editing UI is not being rewritten, and the 2021 deadline does not move. Deciding the migration date at the same time as installing it is the difference between a plan and a deferral, and writing that date into the filter as a comment is what makes it survive a change of maintainer.

Verifying it worked

# every editing screen, on a copy of the real site, with real plugins
$ wp db export before.sql
$ wp core update --version=5.0

#   post, page, product, case study — each opened and saved
#   every meta box present and functional
#   every custom block inserted, saved, reloaded, no validation warning
#   the front end diffed against a crawl taken before the upgrade

$ wget -r -q -l3 https://staging.example -P after/
$ diff -rq before/ after/ | grep -v 'nonce|timestamp'
# no output

$ wp plugin list --status=active --field=name | 
    xargs -I{} wp eval "echo '{}: ' . (function_exists('...') ? 'ok' : 'CHECK');"

Diffing a crawl of the front end before and after is the check that catches the thing nobody thinks to test: a plugin whose output changed because it was filtering the_content and the block markup is different from what the classic editor produced. Filtering out nonces and timestamps is what makes the diff readable.

Opening and saving each post type is manual and there is no substitute. The validation warning in particular only appears when a post containing a block is opened, so a site can look perfectly healthy until an editor touches an old page.

What this costs

A JavaScript toolchain in a codebase that had none. Every plugin with a custom block now needs Node, a build, a lockfile and a story about how the built assets get into the distributed plugin — committed, or built in CI, and both have consequences. For a team of PHP developers maintaining a dozen small plugins, that is a genuine capability that has to be acquired rather than a step to be added.

The second cost is that the abstraction is now much thicker. Debugging a block means reading React, the editor’s own data layer and the REST API, where the equivalent problem in a meta box was a PHP function and a form field. That is not a criticism of the direction — the editor is better and the extensibility is real — but the floor for a WordPress developer moved a long way in one release, and pretending otherwise does nobody any favours.