Disappearing admin meta boxes!?

Looking for feedback on an irritating issue (unlikely to be roots-related, but I can’t be sure, and so bad, that I’m turning to every channel I know with serious WP expertise): Have you encountered problems with admin meta boxes occasionally disappearing for just one user (sometimes an admin)?

So far, BTW, this has been experienced in a handful of sites, all running on Trellis and Bedrock (though I’d be surprised if either is the culprit here), but no Sage.

I’ve seen the admin boxes “come back” within a day (likely with a cache dump along the way, but I can’t be sure). All affected meta boxes have been created with direct calls to Core (rather than a meta-box-making plugin). And this generally happens such that multiple custom meta boxes added to a CPT all disappear at the same time.

Here is an example of the type of meta box that suddenly disappears sometimes:

    new Prefix_Seminar_MB_Dates;

    class Prefix_Seminar_MB_Dates {

        public function __construct() {

            if ( is_admin() ) {
                add_action( 'load-post.php',     array( $this, 'init_metabox' ) );
                add_action( 'load-post-new.php', array( $this, 'init_metabox' ) );
            }

        }

        public function init_metabox() {

            add_action( 'add_meta_boxes',        array( $this, 'add_metabox' )         );
            add_action( 'save_post',             array( $this, 'save_metabox' ), 10, 2 );

        }

        public function add_metabox() {

            add_meta_box(
                'prefix_seminar_mb_dates',
                __( 'Seminar Dates', 'textdomain' ),
                array( $this, 'prefix_seminar_mb_dates_render' ),
                'cpt-seminar',
                'under-title',
                'core'
            );

        }

        public function prefix_seminar_mb_dates_render( $post ) {

            //  Add nonce for security and authentication
            wp_nonce_field( 'nonce_action', 'nonce' );

            //  Retrieve existing values from database
            $prefix_seminar_mb_dates_start = get_post_meta( $post->ID, 'prefix_seminar_mb_dates_start', true );
            $prefix_seminar_mb_dates_end   = get_post_meta( $post->ID, 'prefix_seminar_mb_dates_end',   true );
            $prefix_seminar_mb_dates_year  = get_post_meta( $post->ID, 'prefix_seminar_mb_dates_year',  true );

            //  Set default values
            if( empty( $prefix_seminar_mb_dates_start ) ) $prefix_seminar_mb_dates_start = '0001-01-01';
            if( empty( $prefix_seminar_mb_dates_end ) )   $prefix_seminar_mb_dates_end   = '0001-01-01';
            if( empty( $prefix_seminar_mb_dates_year ) )  $prefix_seminar_mb_dates_year  = '0001';

            // Form fields
            echo '<table class="form-table">';

            echo '	<tr>';
            echo '		<th style="text-align: right;"><label for="prefix_seminar_mb_dates_year" class="prefix_seminar_mb_dates_year_label">' . __( 'Seminar Year', 'textdomain' ) . '</label></th>';
            echo '		<td>';
            echo '			<input type="text" id="prefix_seminar_mb_dates_year" name="prefix_seminar_mb_dates_year" class="prefix_seminar_mb_dates_year_field" placeholder="' . esc_attr__( '', 'textdomain' ) . '" value="' . esc_attr( $prefix_seminar_mb_dates_year ) . '">';
            echo '		</td>';

            echo '		<th style="text-align: right;"><label for="prefix_seminar_mb_dates_start" class="prefix_seminar_mb_dates_start_label">' . __( 'Seminar Start Date', 'textdomain' ) . '</label></th>';
            echo '		<td>';
            echo '			<input type="date" id="prefix_seminar_mb_dates_start" name="prefix_seminar_mb_dates_start" class="prefix_seminar_mb_dates_start_field" placeholder="' . esc_attr__( '', 'textdomain' ) . '" value="' . esc_attr( $prefix_seminar_mb_dates_start ) . '">';
            echo '		</td>';

            echo '		<th style="text-align: right;"><label for="prefix_seminar_mb_dates_end" class="prefix_seminar_mb_dates_end_label">' . __( 'Seminar End Date', 'textdomain' ) . '</label></th>';
            echo '		<td>';
            echo '			<input type="date" id="prefix_seminar_mb_dates_end" name="prefix_seminar_mb_dates_end" class="prefix_seminar_mb_dates_end_field" placeholder="' . esc_attr__( '', 'textdomain' ) . '" value="' . esc_attr( $prefix_seminar_mb_dates_end ) . '">';
            echo '		</td>';
            echo '	</tr>';

            echo '</table>';

        }

        public function save_metabox( $post_id, $post ) {

            //  Add nonce for security and authentication
            $nonce_name   = isset( $_POST['nonce'] ) ? $_POST['nonce'] : '';
            $nonce_action = 'nonce_action';

            if ( ! isset( $nonce_name ) )                             return;  //  Check if nonce is set

            if ( ! wp_verify_nonce( $nonce_name, $nonce_action ) )    return;  //  Check if nonce is valid

            if ( ! current_user_can( 'edit_post', $post_id ) )        return;  //  Check if user has permissions to save data

            if ( wp_is_post_autosave( $post_id ) )                    return;  //  Check if not an autosave

            if ( wp_is_post_revision( $post_id ) )                    return;  //  Check if not a revision

            // Sanitize user input.
            $new_prefix_seminar_mb_dates_start = isset( $_POST[ 'prefix_seminar_mb_dates_start' ] ) ? sanitize_text_field( $_POST[ 'prefix_seminar_mb_dates_start' ] ) : '';
            $new_prefix_seminar_mb_dates_end   = isset( $_POST[ 'prefix_seminar_mb_dates_end' ] )   ? sanitize_text_field( $_POST[ 'prefix_seminar_mb_dates_end' ] )   : '';
            $new_prefix_seminar_mb_dates_year  = isset( $_POST[ 'prefix_seminar_mb_dates_year' ] )  ? sanitize_text_field( $_POST[ 'prefix_seminar_mb_dates_year' ] )  : '';

            // Update the meta field in the database.
            update_post_meta( $post_id, 'prefix_seminar_mb_dates_start', $new_prefix_seminar_mb_dates_start );
            update_post_meta( $post_id, 'prefix_seminar_mb_dates_end', $new_prefix_seminar_mb_dates_end );
            update_post_meta( $post_id, 'prefix_seminar_mb_dates_year', $new_prefix_seminar_mb_dates_year );

        }

    }

And here is the code that provides the “under-title” context:

    //  Move meta boxes in "under-title" custom context to between title and editor
    add_action( 'edit_form_after_title', function() {

        global $post, $wp_meta_boxes;
        do_meta_boxes( get_current_screen(), 'under-title', $post );
        unset( $wp_meta_boxes[get_post_type( $post )]['under-title']);

    });

Additional Information:

  1. All missing meta boxes are showing as checked in Screen Options

  2. None of the markup from any of the missing meta boxes is visible in the Inspector

You probably already know that, but a reminder that those meta boxes can be enabled/disabled on the edit page.

Are those meta boxes in Classic Editor or in Gutenberg Editor?

All the missing meta boxes are enabled in the admin (that’s what I meant by "missing meta boxes are showing as checked in Screen Options).

These are all Classic Editor meta boxes (see code samples above).

(And thank you for chiming in; this is driving me nuts…)

How is the code you’re showing us loaded?

If the problem is intermittent and not reliably replicable, my first guess would be some kind of race condition, ie potentially the code to create the custom context triggers too late (sometimes) and so the meta boxes are added to a nonexistent context. Do you see the same problems if you add the meta boxes to one of the default contexts?

Interesting idea, but wouldn’t loading the custom context code in the edit_form_after_title action, and loading all meta boxes in the add_meta_boxes essentially take care of the loading order?

As it happens, though, the custom context code is require_once'd very early in functions.php, while all meta boxes are require_once'd later in functions.php.

I did notice, however, that the custom context code is require_once'd within an if ( is_admin() ) { ... } check, while most meta boxes are require_once'd after that check (and after the included else). Now, this doesn’t look wrong to me per se. But your question made me wonder whether a user jumping back and forth between Admin and Public, might have something in their session that is thrown by this selective loading.

Thoughts?

Have you checked the entries in usermeta? We had cases in the past where the data there got corrupted somehow. Deleting usually fixed the problem. (gets recreated on the next pageload)

metaboxhidden_post
meta-box-order_post

These are present for every post_type and also for the dashboard. Just look for meta_keys starting with meta.

Very meta :smiley:

Easy way to find out would be to remove that conditional and see if behavior changes.

Brilliant!

This indeed did the trick. Proven with the same user, separately, in Staging and then in Production.

Did you ever discover how/why/when you experienced this corruption? Any chance anything I described earlier in this thread sounds familiar as a possible cause?

I suppose I can get used to responding to user complaints with this sort of cleanup, or, if we find this is indeed an ongoing situation, maybe we can programmatically dump these entries fairly regularly, but that sure seems like a messy solution… So any hint about likely causes for this sort of thing would be greatly appreciated!

Well, I don’t know about “easy”, given the intermittent nature of this issue. Nevertheless, yes, I have already pulled all requires in functions.php out of this sort of conditional.

Meanwhile, given the solution @phenke suggested (which is working so far), at least we have a response ready in case the problem resurfaces.