From dd8a252fc61c56363b4ece437e41a63fad6dcc24 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 19 Mar 2026 15:48:43 +1100 Subject: [PATCH 1/3] Fix the block hooks duplication bug --- packages/core-data/src/resolvers.js | 15 ++++++++++- packages/core-data/src/utils/crdt.ts | 28 +++++++++++++++++++- packages/core-data/src/utils/test/crdt.ts | 32 +++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 1abd7d7e669b30..9b1e4de0cca298 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -28,6 +28,7 @@ import { } from './utils'; import { fetchBlockPatterns } from './fetch'; import { restoreSelection, getSelectionHistory } from './utils/crdt-selection'; +import { stripServerManagedMeta } from './utils/crdt'; /** * Requests authors from the REST API. @@ -247,13 +248,25 @@ export const getEntityRecord = return; } + // Shallow-clone the record and its meta so we + // don't mutate the cached selector result. + // Strip server-managed meta keys before saving — + // sending their (possibly stale) client values + // can overwrite what the server calculates + // during save (e.g. _wp_ignored_hooked_blocks). + const recordToSave = { + ...editedRecord, + meta: { ...meta }, + }; + stripServerManagedMeta( recordToSave ); + // Trigger a save to persist the CRDT document. The entity's // pre-persist hooks will create the persisted CRDT document // and apply it to the record's meta. dispatch.saveEntityRecord( kind, name, - editedRecord + recordToSave ); } ); }, diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 59f0c9439385c8..76d79867103d68 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -76,11 +76,37 @@ export interface YPostRecord extends YMapRecord { export const POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document'; -// Post meta keys that should *not* be synced. +// Post meta keys that should *not* be synced between peers via the CRDT doc. const disallowedPostMetaKeys = new Set< string >( [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, ] ); +// Post meta keys that are server-managed and must not be sent by the client +// during saves. Sending stale client values for these keys overwrites the +// server's calculations (e.g. update_ignored_hooked_blocks_postmeta). +const serverManagedPostMetaKeys = new Set< string >( [ + '_wp_ignored_hooked_blocks', +] ); + +/** + * Remove server-managed meta keys from a record's meta object in place. + * These keys are calculated by the server during save and must not be + * overwritten by possibly stale client values. + * + * @param {Object} record An entity record with an optional `meta` property. + */ +export function stripServerManagedMeta( + record: Record< string, unknown > +): void { + const meta = record.meta; + if ( ! meta || typeof meta !== 'object' ) { + return; + } + serverManagedPostMetaKeys.forEach( ( metaKey ) => { + delete ( meta as Record< string, unknown > )[ metaKey ]; + } ); +} + /** * Given a set of local changes to a generic entity record, apply those changes * to the local Y.Doc. diff --git a/packages/core-data/src/utils/test/crdt.ts b/packages/core-data/src/utils/test/crdt.ts index d14af7ccc68d23..53906e64b0f632 100644 --- a/packages/core-data/src/utils/test/crdt.ts +++ b/packages/core-data/src/utils/test/crdt.ts @@ -59,6 +59,7 @@ import { applyPostChangesToCRDTDoc, getPostChangesFromCRDTDoc, POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, + stripServerManagedMeta, type PostChanges, type YPostRecord, } from '../crdt'; @@ -932,3 +933,34 @@ function addBlockToDoc( return ytext; } + +describe( 'stripServerManagedMeta', () => { + it( 'should remove _wp_ignored_hooked_blocks from meta', () => { + const record = { + meta: { + _wp_ignored_hooked_blocks: '', + footnotes: '', + }, + }; + stripServerManagedMeta( record ); + expect( record.meta ).toEqual( { footnotes: '' } ); + } ); + + it( 'should be a no-op when the key is not present', () => { + const record = { meta: { footnotes: '' } }; + stripServerManagedMeta( record ); + expect( record.meta ).toEqual( { footnotes: '' } ); + } ); + + it( 'should be a no-op when meta is null', () => { + const record = { meta: null }; + stripServerManagedMeta( record ); + expect( record.meta ).toBeNull(); + } ); + + it( 'should be a no-op when meta is undefined', () => { + const record = {}; + stripServerManagedMeta( record ); + expect( record ).toEqual( {} ); + } ); +} ); From fcfa88ed05c889e8af19822cc4a4863ebe4827ef Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 19 Mar 2026 15:56:19 +1100 Subject: [PATCH 2/3] Remove the collaboration work around in the block hooks e2e tests --- .../specs/editor/plugins/block-hooks.spec.js | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/test/e2e/specs/editor/plugins/block-hooks.spec.js b/test/e2e/specs/editor/plugins/block-hooks.spec.js index 249be208ae522f..f90cdbccb54d70 100644 --- a/test/e2e/specs/editor/plugins/block-hooks.spec.js +++ b/test/e2e/specs/editor/plugins/block-hooks.spec.js @@ -3,13 +3,6 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); -/** - * Internal dependencies - */ -const { - setCollaboration, -} = require( '../../editor/collaboration/fixtures/collaboration-utils' ); - const dummyBlocksContent = `

This is a dummy heading

@@ -72,12 +65,6 @@ test.describe( 'Block Hooks API', () => { } else { containerPost = postObject; } - - /** - * Since the Block Hooks API relies on server-side rendering to insert - * the hooked blocks, there is a fundamental incompatibility with RTC. - */ - await setCollaboration( requestUtils, false ); } ); test.afterAll( async ( { requestUtils } ) => { @@ -87,7 +74,6 @@ test.describe( 'Block Hooks API', () => { await requestUtils.deleteAllPosts(); await requestUtils.deleteAllBlocks(); - await setCollaboration( requestUtils, true ); } ); test( `should insert hooked blocks into ${ name } on frontend`, async ( { @@ -212,12 +198,6 @@ test.describe( 'Block Hooks API', () => { } else { containerPost = postObject; } - - /** - * Since the Block Hooks API relies on server-side rendering to insert - * the hooked blocks, there is a fundamental incompatibility with RTC. - */ - await setCollaboration( requestUtils, false ); } ); test.afterAll( async ( { requestUtils } ) => { @@ -227,7 +207,6 @@ test.describe( 'Block Hooks API', () => { await requestUtils.deleteAllPosts(); await requestUtils.deleteAllBlocks(); - await setCollaboration( requestUtils, true ); } ); test( `should insert hooked blocks into ${ name } on frontend`, async ( { From a687c2b6f0ea268cd9d63f2d3f199098b3621ab9 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 30 Mar 2026 11:49:45 +1100 Subject: [PATCH 3/3] Drop unnecessary comment from crdt.ts Co-Authored-By: Claude Opus 4.6 --- packages/core-data/src/utils/crdt.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 76d79867103d68..04bc37015e74d4 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -76,7 +76,6 @@ export interface YPostRecord extends YMapRecord { export const POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document'; -// Post meta keys that should *not* be synced between peers via the CRDT doc. const disallowedPostMetaKeys = new Set< string >( [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, ] );