Fix Orphaned Block Notes After WordPress Import
WordPress 6.9 introduced Block Notes, a long-overdue collaboration feature that lets your team leave contextual feedback directly on blocks in the editor. Notes appear as indicators in the block toolbar and can be resolved, replied to, and edited. They’re a big deal for editorial workflows.
There’s just one problem: if you export and import content that contains Block Notes, the notes break. They’re still attached to the post, but they’re no longer linked to their blocks. The toolbar indicators vanish, and your review notes end up orphaned in the comments list.
We discovered this while programmatically generating 40 service pages with Block Notes embedded in the WXR import file, a workflow where every page needed review flags on specific blocks.
What Causes the Bug
Block Notes use two linked pieces of data. First, a "noteId" value stored in each block’s metadata attribute inside post_content. Second, a WP_Comment record with comment_type of "note" whose comment_id matches that noteId.
When the WordPress Importer processes a WXR file, it correctly reassigns comment_id values to avoid collisions with existing comments. But it does not update the corresponding "noteId" references inside post_content. The comment gets a new ID; the block still points to the old one. The link is broken.
We’ve filed this as Trac ticket #64724.
Side note: We love that they used the comments engine to build this, we’ve done similar with custom solutions.
How Block Notes Work in WXR
If you’re building WXR files that include Block Notes, whether programmatically or through export/import, here’s how the two pieces connect.
In the block markup inside post_content, the target block gets a noteId in its metadata attribute:
<!-- wp:heading {"level":1,"metadata":{"noteId":3252}} -->
<h1 class="wp-block-heading">Page Title</h1>
<!-- /wp:heading -->
Then in the same <item>, a wp:comment element with comment_type of note carries the actual note text. The comment_id must match the noteId in the block:
<wp:comment>
<wp:comment_id>3252</wp:comment_id>
<wp:comment_content><![CDATA[Review this heading]]></wp:comment_content>
<wp:comment_approved><![CDATA[0]]></wp:comment_approved>
<wp:comment_type><![CDATA[note]]></wp:comment_type>
<wp:comment_parent>0</wp:comment_parent>
<wp:comment_user_id>1</wp:comment_user_id>
</wp:comment>
When the importer assigns new comment_id values (say, 3252 becomes 8901), the block still references "noteId":3252. That’s the mismatch.
Then, when you look at your notes on the imported post, you’ll see “Original block deleted.“
The Fix: Relink Block Notes After Import
The following PHP script finds all posts with orphaned Block Notes, maps old noteId values to their new comment_id values, and updates post_content to restore the links. It’s safe to run multiple times, and if all notes are already correctly linked, it skips the post entirely.
The Script
<?php
/**
* Relink WordPress Block Notes after WXR import.
*
* The WordPress Importer reassigns comment_id values but does NOT update
* the corresponding "noteId" references in block metadata (post_content).
* This script fixes that by matching old-to-new IDs via import order.
*
* Usage (WP-CLI): wp eval-file relink-block-notes.php
* Usage (mu-plugin): Copy to wp-content/mu-plugins/ — runs on admin load.
*
* Safe to run multiple times.
*
* @see https://core.trac.wordpress.org/ticket/64724
*/
// If loaded as mu-plugin, run via admin_init.
// Tracks the highest note comment_ID to detect new imports.
// The importer assigns new sequential IDs regardless of the
// original comment_date in the export file, so imported notes
// always have higher IDs than what existed before.
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
add_action( 'admin_init', function() {
global $wpdb;
$last_max = (int) get_option(
'relink_block_notes_max_id', 0
);
$current_max = (int) $wpdb->get_var(
"SELECT MAX(comment_ID) FROM {$wpdb->comments}
WHERE comment_type = 'note'"
);
if ( ! $current_max || $current_max <= $last_max ) {
return; // No new note comments since last run.
}
$results = relink_block_notes();
update_option( 'relink_block_notes_max_id', $current_max );
if ( $results['fixed'] > 0 ) {
add_action( 'admin_notices', function() use ( $results ) {
echo '<div class="notice notice-success is-dismissible">';
echo '<p><strong>Block Notes Relinked:</strong> ';
echo esc_html( $results['summary'] ) . '</p>';
echo '<p>You can now delete ';
echo '<code>mu-plugins/relink-block-notes.php</code>.';
echo '</p></div>';
});
}
});
}
/**
* Find and fix all orphaned noteId references.
*/
function relink_block_notes() {
global $wpdb;
$result = array(
'fixed' => 0, 'posts' => 0,
'notes' => 0, 'summary' => '',
);
// Find all posts that have note-type comments.
$post_ids = $wpdb->get_col(
"SELECT DISTINCT comment_post_ID
FROM {$wpdb->comments}
WHERE comment_type = 'note'"
);
if ( empty( $post_ids ) ) {
$result['summary'] = 'No posts with block notes found.';
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::log( $result['summary'] );
}
return $result;
}
foreach ( $post_ids as $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
continue;
}
$notes = get_comments( array(
'post_id' => $post_id,
'type' => 'note',
'status' => 'any',
) );
if ( empty( $notes ) ) {
continue;
}
$result['notes'] += count( $notes );
// Extract all noteId values from post_content.
$content = $post->post_content;
preg_match_all( '/"noteId":(\d+)/', $content, $matches );
if ( empty( $matches[1] ) ) {
continue;
}
// If all noteIds already match existing comment IDs, skip.
$current_ids = array_map( 'intval',
wp_list_pluck( $notes, 'comment_ID' )
);
$content_ids = array_map( 'intval', $matches[1] );
if ( empty( array_diff( $content_ids, $current_ids ) ) ) {
continue;
}
// Build old-to-new mapping via NUMERIC sort.
//
// WordPress exports comments sorted by comment_id
// ascending. The importer processes them in that order
// and assigns new sequential IDs. So:
// old noteIds sorted ascending = export order
// new comment_ids sorted ascending = import order
// Pair them positionally for the correct mapping.
$old_sorted = array_unique( $content_ids );
sort( $old_sorted, SORT_NUMERIC );
$new_sorted = $current_ids;
sort( $new_sorted, SORT_NUMERIC );
$count = min( count( $old_sorted ), count( $new_sorted ) );
$replacements = array();
for ( $i = 0; $i < $count; $i++ ) {
if ( $old_sorted[ $i ] !== $new_sorted[ $i ] ) {
$replacements[ $old_sorted[ $i ] ] = $new_sorted[ $i ];
}
}
if ( empty( $replacements ) ) {
continue;
}
// Apply replacements (longest IDs first).
$new_content = $content;
krsort( $replacements );
foreach ( $replacements as $old_id => $new_id ) {
$new_content = str_replace(
'"noteId":' . $old_id,
'"noteId":' . $new_id,
$new_content
);
}
if ( $new_content !== $content ) {
$wpdb->update(
$wpdb->posts,
array( 'post_content' => $new_content ),
array( 'ID' => $post_id ),
array( '%s' ),
array( '%d' )
);
clean_post_cache( $post_id );
$result['fixed'] += count( $replacements );
$result['posts']++;
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::log( sprintf(
'Post %d (%s): relinked %d/%d notes',
$post_id, $post->post_title,
count( $replacements ), count( $notes )
) );
}
}
}
$result['summary'] = sprintf(
'Done. Fixed %d noteId references across %d posts'
. ' (%d total notes found).',
$result['fixed'], $result['posts'], $result['notes']
);
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::success( $result['summary'] );
}
return $result;
}
// WP-CLI: execute immediately.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
relink_block_notes();
}
Option A: WP-CLI
If you have shell access, this is the fastest path:
wp eval-file relink-block-notes.php
Option B: Must-Use Plugin
No SSH? Drop the file into wp-content/mu-plugins/ and visit any admin page. It tracks the highest note comment_ID after each run, so it only fires when new note comments appear (e.g. from another import) and skips cleanly on every other page load. Keep it there for future imports, or remove it after you finish.
How the Matching Works
The key insight: WordPress exports comments sorted by comment_id ascending, and the importer processes them in that same order, assigning new sequential IDs. So the nth-smallest old ID always maps to the nth-smallest new ID.
The script collects the old noteId values from post_content, sorts them numerically ascending, sorts the new comment_id values ascending, and pairs them positionally. This is important because a note on the last block of a page can have a lower comment_id than a note on the first block if it was created first. Numeric sort handles this correctly.
When You Need This
This fix applies any time Block Notes lose their block association after import. Common scenarios include migrating content between WordPress sites, importing WXR files that contain Block Notes, programmatically generating pages with embedded review notes, and restoring from a backup where comment IDs have shifted. If you see notes attached to a post in the comments panel but no indicators on blocks in the editor, this is likely the cause.
This worked perfectly for our use case. We hope it helps you, too, until they fix the bug.
(Last Updated: February 25, 2026)
This post was developed in collaboration with Claude 4.6 Opus, ChatGPT 5.2 Thinking & Gemini 3 Pro. Image generated using Midjourney 7 and Adobe Firefly via Photoshop. The final content was edited, tested, formatted, and fact-checked by Erik Lutenegger.
Read Other Technical Snippets
[email protected]
Need help with WordPress Migrations or Block Notes?