} entries Per-clientId entries.
+ * @property {Function} captureBaseline Store a baseline for a
+ * block if one isn't set.
+ * @property {Function} setOverlayAttributes Merge overlay attributes
+ * onto an entry.
+ * @property {Function} clearOverlay Remove the entry.
+ * @property {Function} hasOverlay Check if an entry has any
+ * overlay attributes.
+ */
+
+const EMPTY_ENTRIES = Object.freeze( {} );
+
+const OverlayContext = createContext( {
+ entries: EMPTY_ENTRIES,
+ captureBaseline: () => {},
+ setOverlayAttributes: () => {},
+ clearOverlay: () => {},
+ hasOverlay: () => false,
+} );
+
+/**
+ * Reducer managing the map of pending block overlays.
+ *
+ * @param {Object} state Current state.
+ * @param {Object} action Action.
+ * @return {Object} Next state.
+ */
+export function overlayReducer( state, action ) {
+ switch ( action.type ) {
+ case 'CAPTURE_BASELINE': {
+ if ( state[ action.clientId ] ) {
+ return state;
+ }
+ return {
+ ...state,
+ [ action.clientId ]: {
+ blockName: action.blockName,
+ baselineAttributes: action.attributes,
+ overlayAttributes: {},
+ },
+ };
+ }
+ case 'SET_OVERLAY_ATTRIBUTES': {
+ const entry = state[ action.clientId ];
+ if ( ! entry ) {
+ return state;
+ }
+ return {
+ ...state,
+ [ action.clientId ]: {
+ ...entry,
+ overlayAttributes: {
+ ...entry.overlayAttributes,
+ ...action.attributes,
+ },
+ },
+ };
+ }
+ case 'CLEAR_OVERLAY': {
+ if ( ! state[ action.clientId ] ) {
+ return state;
+ }
+ const { [ action.clientId ]: _removed, ...rest } = state;
+ return rest;
+ }
+ case 'PRUNE_ORPHANS': {
+ // Action carries a serializable array; the reducer materializes a
+ // Set internally for the lookup. Keeps actions Redux-DevTools-
+ // friendly (Sets aren't serializable for time-travel).
+ const liveIds = Array.isArray( action.liveClientIds )
+ ? new Set( action.liveClientIds )
+ : action.liveClientIds;
+ const keys = Object.keys( state );
+ let changed = false;
+ const next = {};
+ for ( const key of keys ) {
+ if ( liveIds.has( key ) ) {
+ next[ key ] = state[ key ];
+ } else {
+ changed = true;
+ }
+ }
+ return changed ? next : state;
+ }
+ default:
+ return state;
+ }
+}
+
+/**
+ * Provider exposing the suggestion overlay to descendant blocks.
+ *
+ * The overlay is intentionally in-memory only. It stores pending attribute
+ * changes per `clientId` so a block can render the user's in-progress
+ * suggestion without mutating the real block-editor state.
+ *
+ * @param {{ children: React.ReactNode }} props
+ */
+export function SuggestionOverlayProvider( { children } ) {
+ const [ entries, dispatch ] = useReducer( overlayReducer, EMPTY_ENTRIES );
+ const registry = useRegistry();
+
+ const captureBaseline = useCallback(
+ ( clientId, blockName, attributes ) =>
+ dispatch( {
+ type: 'CAPTURE_BASELINE',
+ clientId,
+ blockName,
+ attributes,
+ } ),
+ []
+ );
+
+ const setOverlayAttributes = useCallback(
+ ( clientId, attributes ) =>
+ dispatch( {
+ type: 'SET_OVERLAY_ATTRIBUTES',
+ clientId,
+ attributes,
+ } ),
+ []
+ );
+
+ const clearOverlay = useCallback(
+ ( clientId ) => dispatch( { type: 'CLEAR_OVERLAY', clientId } ),
+ []
+ );
+
+ const hasEntries = Object.keys( entries ).length > 0;
+
+ const hasOverlay = useCallback(
+ ( clientId ) => {
+ const entry = entries[ clientId ];
+ return (
+ !! entry && Object.keys( entry.overlayAttributes ).length > 0
+ );
+ },
+ [ entries ]
+ );
+
+ // Prune overlay entries whose block was removed from the editor. This
+ // prevents stale baselines from persisting after a block is deleted.
+ // The block-count subscription only runs when there are entries to
+ // prune; in Edit / View intent (no entries) there's no point watching
+ // the block tree at all.
+ const blockCount = useSelect(
+ ( select ) => {
+ if ( ! hasEntries ) {
+ return 0;
+ }
+ const blockEditor = select( BLOCK_EDITOR_STORE_NAME );
+ return blockEditor?.getClientIdsWithDescendants?.().length ?? 0;
+ },
+ [ hasEntries ]
+ );
+ useEffect( () => {
+ if ( ! hasEntries ) {
+ return;
+ }
+ const getLive = registry.select(
+ BLOCK_EDITOR_STORE_NAME
+ )?.getClientIdsWithDescendants;
+ if ( ! getLive ) {
+ return;
+ }
+ const live = getLive();
+ const liveSet = new Set( live );
+ const hasOrphan = Object.keys( entries ).some(
+ ( key ) => ! liveSet.has( key )
+ );
+ if ( hasOrphan ) {
+ dispatch( { type: 'PRUNE_ORPHANS', liveClientIds: live } );
+ }
+ }, [ hasEntries, blockCount, entries, registry ] );
+
+ const value = useMemo(
+ () => ( {
+ entries,
+ captureBaseline,
+ setOverlayAttributes,
+ clearOverlay,
+ hasOverlay,
+ } ),
+ [
+ entries,
+ captureBaseline,
+ setOverlayAttributes,
+ clearOverlay,
+ hasOverlay,
+ ]
+ );
+
+ return (
+
+ { children }
+
+ );
+}
+
+/**
+ * Hook returning the suggestion overlay API.
+ *
+ * @return {OverlayContextValue} Overlay API.
+ */
+export function useSuggestionOverlay() {
+ return useContext( OverlayContext );
+}
diff --git a/packages/editor/src/components/suggestion-mode/provider.js b/packages/editor/src/components/suggestion-mode/provider.js
new file mode 100644
index 00000000000000..75bf6bb4491939
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/provider.js
@@ -0,0 +1,279 @@
+/**
+ * WordPress dependencies
+ */
+import { useCallback } from '@wordpress/element';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { store as noticesStore } from '@wordpress/notices';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { EDITOR_STORE_NAME } from './constants';
+
+/**
+ * @typedef {Object} SuggestionOperation
+ * @property {'attribute-set'} type Operation type. Only `attribute-set`
+ * is implemented in Phase 2.
+ * @property {string} attribute The attribute being changed.
+ * @property {*} before The baseline value.
+ * @property {*} after The proposed value.
+ */
+
+/**
+ * @typedef {Object} SuggestionPayload
+ * @property {number} schemaVersion Payload schema version.
+ * @property {string} blockName Block name at capture time.
+ * @property {string|null} baseRevision Post `modified_gmt` at
+ * capture, used by Phase 3 to
+ * detect stale suggestions.
+ * @property {SuggestionOperation[]} operations Ordered operations.
+ */
+
+const SCHEMA_VERSION = 1;
+
+/**
+ * Maximum byte length of a serialized suggestion payload. Mirrors
+ * `GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES` in
+ * `lib/compat/wordpress-6.9/block-comments.php`. The client checks before
+ * submitting so a doomed request never leaves the browser; the REST
+ * controller is the authoritative gate.
+ */
+const PAYLOAD_MAX_BYTES = 65536;
+
+/**
+ * Byte length of a serialized payload, measured the way PHP `strlen()`
+ * counts (UTF-8 bytes, not chars).
+ *
+ * @param {SuggestionPayload} payload
+ * @return {number} UTF-8 byte length of the serialized JSON.
+ */
+function payloadByteLength( payload ) {
+ const serialized = JSON.stringify( payload );
+ if ( typeof TextEncoder !== 'undefined' ) {
+ return new TextEncoder().encode( serialized ).length;
+ }
+ // Conservative upper bound: 4 bytes per UTF-16 code unit covers all
+ // possible UTF-8 expansions. Used only in test/JSDOM environments
+ // without TextEncoder.
+ return serialized.length * 4;
+}
+
+/**
+ * Build attribute-set operations by diffing an overlay entry against its
+ * captured baseline. Attributes whose value differs are emitted; unchanged
+ * or absent keys are skipped.
+ *
+ * @param {Object} baselineAttributes Attributes captured on first edit.
+ * @param {Object} overlayAttributes Pending attribute changes.
+ * @return {SuggestionOperation[]} Operations describing the suggestion.
+ */
+export function operationsFromOverlay( baselineAttributes, overlayAttributes ) {
+ const operations = [];
+ for ( const [ attribute, after ] of Object.entries(
+ overlayAttributes || {}
+ ) ) {
+ const before = baselineAttributes?.[ attribute ];
+ if ( ! isAttributeEqual( before, after ) ) {
+ operations.push( {
+ type: 'attribute-set',
+ attribute,
+ before: before ?? null,
+ after,
+ } );
+ }
+ }
+ return operations;
+}
+
+/**
+ * Structural equality for attribute values. Handles primitives, arrays, and
+ * plain objects with arbitrary key order.
+ *
+ * `JSON.stringify` is order-sensitive ({a:1,b:2} ≠ {b:2,a:1}), so a stringify-
+ * based compare produces spurious "changed" detections when block code re-
+ * emits a `style` object with reordered keys. The recursive walk avoids that.
+ *
+ * @param {*} a First value.
+ * @param {*} b Second value.
+ * @return {boolean} True when the values are structurally equal.
+ */
+function isAttributeEqual( a, b ) {
+ if ( a === b ) {
+ return true;
+ }
+ if ( a === null || a === undefined || b === null || b === undefined ) {
+ return false;
+ }
+ if ( typeof a !== 'object' || typeof b !== 'object' ) {
+ return false;
+ }
+ const aIsArray = Array.isArray( a );
+ const bIsArray = Array.isArray( b );
+ if ( aIsArray !== bIsArray ) {
+ return false;
+ }
+ if ( aIsArray ) {
+ if ( a.length !== b.length ) {
+ return false;
+ }
+ for ( let i = 0; i < a.length; i++ ) {
+ if ( ! isAttributeEqual( a[ i ], b[ i ] ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ const aKeys = Object.keys( a );
+ const bKeys = Object.keys( b );
+ if ( aKeys.length !== bKeys.length ) {
+ return false;
+ }
+ for ( const key of aKeys ) {
+ if ( ! Object.prototype.hasOwnProperty.call( b, key ) ) {
+ return false;
+ }
+ if ( ! isAttributeEqual( a[ key ], b[ key ] ) ) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Comment-meta backed suggestions provider. Phase 2 implements only
+ * `createSuggestion`. Apply and reject are stubbed and will be implemented
+ * alongside the diff preview in Phase 3. The provider shape is stable so
+ * Phase 3 / a future Yjs-backed provider can swap in without touching the
+ * UI.
+ *
+ * Storage: a new `note` comment with the suggestion payload serialized to
+ * the `_wp_suggestion` comment meta. Linkage to a block reuses the existing
+ * `metadata.noteId` block attribute.
+ *
+ * @return {{
+ * createSuggestion: Function,
+ * applySuggestion: Function,
+ * rejectSuggestion: Function,
+ * }} Suggestions API.
+ */
+export function useSuggestionsProvider() {
+ const { postId, postModified } = useSelect( ( select ) => {
+ const editor = select( EDITOR_STORE_NAME );
+ const id = editor?.getCurrentPostId?.() ?? null;
+ const postType = editor?.getCurrentPostType?.() ?? null;
+ const record =
+ id && postType
+ ? select( coreStore ).getEditedEntityRecord(
+ 'postType',
+ postType,
+ id
+ )
+ : null;
+ return {
+ postId: id,
+ postModified: record?.modified_gmt ?? null,
+ };
+ }, [] );
+
+ const { saveEntityRecord } = useDispatch( coreStore );
+ const { createNotice } = useDispatch( noticesStore );
+ const { updateBlockAttributes } = useDispatch( blockEditorStore );
+ const { getBlockAttributes: selectBlockAttributes } =
+ useSelect( blockEditorStore );
+
+ const createSuggestion = useCallback(
+ async ( { clientId, blockName, operations } ) => {
+ if ( ! postId ) {
+ throw new Error( 'No post id available for suggestion.' );
+ }
+ if ( ! operations || operations.length === 0 ) {
+ return null;
+ }
+
+ const payload = /** @type {SuggestionPayload} */ ( {
+ schemaVersion: SCHEMA_VERSION,
+ blockName,
+ baseRevision: postModified,
+ operations,
+ } );
+
+ if ( payloadByteLength( payload ) > PAYLOAD_MAX_BYTES ) {
+ const error = new Error(
+ __( 'Suggestion is too large to save.' )
+ );
+ createNotice( 'error', error.message, {
+ type: 'snackbar',
+ isDismissible: true,
+ } );
+ throw error;
+ }
+
+ try {
+ const savedRecord = await saveEntityRecord(
+ 'root',
+ 'comment',
+ {
+ post: postId,
+ content: '',
+ status: 'hold',
+ type: 'note',
+ parent: 0,
+ meta: {
+ _wp_suggestion: JSON.stringify( payload ),
+ },
+ },
+ { throwOnError: true }
+ );
+
+ if ( savedRecord?.id ) {
+ // Merge into existing metadata rather than replacing so
+ // other fields like bindings, name, and block identifiers
+ // are preserved.
+ const existingMeta =
+ selectBlockAttributes( clientId )?.metadata ?? {};
+ updateBlockAttributes( clientId, {
+ metadata: {
+ ...existingMeta,
+ noteId: savedRecord.id,
+ },
+ } );
+ }
+
+ createNotice( 'success', __( 'Suggestion submitted.' ), {
+ type: 'snackbar',
+ isDismissible: true,
+ } );
+ return savedRecord;
+ } catch ( error ) {
+ createNotice(
+ 'error',
+ error?.message || __( 'Unable to submit suggestion.' ),
+ { type: 'snackbar', isDismissible: true }
+ );
+ throw error;
+ }
+ },
+ [
+ postId,
+ postModified,
+ saveEntityRecord,
+ updateBlockAttributes,
+ selectBlockAttributes,
+ createNotice,
+ ]
+ );
+
+ const applySuggestion = useCallback( async () => {
+ throw new Error( 'applySuggestion is not implemented in Phase 2.' );
+ }, [] );
+ const rejectSuggestion = useCallback( async () => {
+ throw new Error( 'rejectSuggestion is not implemented in Phase 2.' );
+ }, [] );
+
+ return { createSuggestion, applySuggestion, rejectSuggestion };
+}
+
+export { SCHEMA_VERSION, PAYLOAD_MAX_BYTES, payloadByteLength };
diff --git a/packages/editor/src/components/suggestion-mode/store-interceptor.js b/packages/editor/src/components/suggestion-mode/store-interceptor.js
new file mode 100644
index 00000000000000..8f83bd2e0d73d9
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/store-interceptor.js
@@ -0,0 +1,405 @@
+/**
+ * Store-level safety net for Suggest mode.
+ *
+ * `withSuggestionOverlay` already diverts attribute changes that go through a
+ * block's `setAttributes` prop. This file handles the other half: mutations
+ * that bypass the prop chain by dispatching `updateBlockAttributes` directly
+ * to the block-editor store. The block-switcher (heading variation H2 → H3)
+ * and any third-party code that uses the data store API land here.
+ *
+ * Strategy (see `SuggestionStoreInterceptor` for the fully-commented flow):
+ * - On Suggest activation, snapshot every block's attributes.
+ * - Subscribe to the registry. When a dispatch lands the subscribe fires;
+ * diff the live attributes against the snapshot.
+ * - For drift on a tracked block: route the changed attributes into the
+ * overlay and dispatch a revert that restores the snapshot.
+ * - The `isReverting` flag suppresses the recursive subscribe fire that
+ * the revert dispatch itself would otherwise trigger.
+ * - System-managed metadata (`metadata.noteId` written by the suggestion
+ * provider after creating a note comment) is folded into the snapshot
+ * before diffing so it's invisible to the diff and never leaks into the
+ * user-pending overlay.
+ *
+ * Why subscribe rather than React state:
+ * - The interceptor must run after the dispatch lands but before any
+ * React re-render serializes the (now wrong) state. `registry.subscribe`
+ * fires synchronously immediately after every store update.
+ * - Subscribe also catches dispatches from non-React paths (CLI scripts,
+ * keyboard handlers, external integrations) that wouldn't trigger a
+ * `useSelect`-based watcher.
+ *
+ * New blocks (no snapshot entry) are tracked but not intercepted — inserting
+ * a block in Suggest mode is currently a real edit, not a suggestion.
+ */
+/**
+ * WordPress dependencies
+ */
+import { useRegistry, useSelect } from '@wordpress/data';
+import { useEffect, useRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { useSuggestionOverlay } from './overlay-context';
+import { EDITOR_STORE_NAME, SUGGEST_INTENT } from './constants';
+
+const BLOCK_EDITOR_STORE_NAME = 'core/block-editor';
+
+/**
+ * Keys under `metadata` that are programmatic linkages set by editor-internal
+ * code (the suggestion provider writes `metadata.noteId` after creating a
+ * note comment to link the block back to its note). These must persist on
+ * the live block — without the linkage, `useNoteThreads` can't resolve a
+ * note's `blockClientId` and the note appears orphaned. The interceptor
+ * folds changes to these keys into its snapshot before diffing so they are
+ * never reverted and never routed into the user-pending overlay.
+ */
+const SYSTEM_METADATA_KEYS = new Set( [ 'noteId' ] );
+
+/**
+ * Compare two attribute values structurally. Mirrors `isAttributeEqual` in
+ * provider.js — kept as a private helper here so this module doesn't pull
+ * in the provider's hooks just for the comparison.
+ *
+ * @param {*} a First value.
+ * @param {*} b Second value.
+ * @return {boolean} True when the two values are structurally equal.
+ */
+function shallowAttributeEquals( a, b ) {
+ if ( a === b ) {
+ return true;
+ }
+ if ( a === null || a === undefined || b === null || b === undefined ) {
+ return false;
+ }
+ if ( typeof a !== 'object' || typeof b !== 'object' ) {
+ return false;
+ }
+ const aIsArray = Array.isArray( a );
+ const bIsArray = Array.isArray( b );
+ if ( aIsArray !== bIsArray ) {
+ return false;
+ }
+ if ( aIsArray ) {
+ if ( a.length !== b.length ) {
+ return false;
+ }
+ for ( let i = 0; i < a.length; i++ ) {
+ if ( ! shallowAttributeEquals( a[ i ], b[ i ] ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ const aKeys = Object.keys( a );
+ const bKeys = Object.keys( b );
+ if ( aKeys.length !== bKeys.length ) {
+ return false;
+ }
+ for ( const key of aKeys ) {
+ if ( ! Object.prototype.hasOwnProperty.call( b, key ) ) {
+ return false;
+ }
+ if ( ! shallowAttributeEquals( a[ key ], b[ key ] ) ) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Diff two attribute objects, returning a map of `{ key: currentValue }` for
+ * keys whose value has changed and `{ key: previousValue }` for the keys that
+ * need to be restored on the block.
+ *
+ * @param {Object} previous Attributes before the mutation.
+ * @param {Object} current Attributes after the mutation.
+ * @return {{ changed: Object, restore: Object }|null} Per-key delta, or null
+ * when no keys changed.
+ */
+function diffAttributes( previous, current ) {
+ const changed = {};
+ const restore = {};
+ let hasChange = false;
+ const seen = new Set();
+
+ for ( const key of Object.keys( current ) ) {
+ seen.add( key );
+ const prevValue = previous?.[ key ];
+ const currValue = current[ key ];
+ if ( ! shallowAttributeEquals( prevValue, currValue ) ) {
+ changed[ key ] = currValue;
+ restore[ key ] = prevValue ?? undefined;
+ hasChange = true;
+ }
+ }
+
+ for ( const key of Object.keys( previous ?? {} ) ) {
+ if ( seen.has( key ) ) {
+ continue;
+ }
+ // Key was removed by the mutation.
+ changed[ key ] = undefined;
+ restore[ key ] = previous[ key ];
+ hasChange = true;
+ }
+
+ return hasChange ? { changed, restore } : null;
+}
+
+/**
+ * Fold the values of system-managed metadata keys from `current` into a copy
+ * of `previous`. The result is used as the snapshot baseline for the next
+ * diff so a programmatic update to (e.g.) `metadata.noteId` is invisible to
+ * the diff and the revert payload preserves the new value.
+ *
+ * Returns `previous` unchanged when no system key has drifted.
+ *
+ * @param {Object} previous Snapshot attributes.
+ * @param {Object} current Live attributes.
+ * @return {Object} Snapshot attributes with system metadata adopted.
+ */
+function adoptSystemMetadata( previous, current ) {
+ const previousMeta = previous?.metadata ?? {};
+ const currentMeta = current?.metadata ?? {};
+ let nextMeta = previousMeta;
+ let touched = false;
+ for ( const key of SYSTEM_METADATA_KEYS ) {
+ const prevValue = previousMeta[ key ];
+ const currValue = currentMeta[ key ];
+ if ( prevValue === currValue ) {
+ continue;
+ }
+ if ( ! touched ) {
+ nextMeta = { ...previousMeta };
+ touched = true;
+ }
+ if ( currValue === undefined ) {
+ delete nextMeta[ key ];
+ } else {
+ nextMeta[ key ] = currValue;
+ }
+ }
+ if ( ! touched ) {
+ return previous;
+ }
+ return {
+ ...previous,
+ metadata: nextMeta,
+ };
+}
+
+/**
+ * Strip system-managed metadata keys from a `changed` payload destined for
+ * the suggestion overlay. The overlay represents user-pending edits; system
+ * fields such as `metadata.noteId` must never appear there because they are
+ * not part of the user's suggestion and would otherwise leak into the
+ * persisted suggestion operations.
+ *
+ * Drops the `metadata` key entirely when no non-system fields remain.
+ *
+ * @param {Object} changed `delta.changed` from `diffAttributes`.
+ * @return {Object} Filtered payload safe for the overlay.
+ */
+function stripSystemMetadata( changed ) {
+ const meta = changed?.metadata;
+ if ( ! meta || typeof meta !== 'object' ) {
+ return changed;
+ }
+ let stripped = meta;
+ let touched = false;
+ for ( const key of SYSTEM_METADATA_KEYS ) {
+ if ( Object.prototype.hasOwnProperty.call( meta, key ) ) {
+ if ( ! touched ) {
+ stripped = { ...meta };
+ touched = true;
+ }
+ delete stripped[ key ];
+ }
+ }
+ if ( ! touched ) {
+ return changed;
+ }
+ if ( Object.keys( stripped ).length === 0 ) {
+ const { metadata: _drop, ...rest } = changed;
+ return rest;
+ }
+ return { ...changed, metadata: stripped };
+}
+
+/**
+ * Invisible component that catches block-attribute mutations dispatched
+ * directly to the block-editor store while the editor is in Suggest intent.
+ *
+ * The `editor.BlockEdit` HOC already intercepts `setAttributes` calls that
+ * blocks make through their own props. But some Gutenberg paths bypass that
+ * prop chain — most notably the block-switcher's variation picker, which
+ * calls `updateBlockAttributes( clientId, { level } )` directly to swap a
+ * heading from H2 → H3. Without this interceptor those mutations would land
+ * in the post unmodified, defeating Suggest mode.
+ *
+ * Strategy: snapshot every block's attributes when Suggest intent activates,
+ * then on every block-editor state change diff the live tree against the
+ * snapshot. Any block whose attributes drift from the snapshot has its
+ * change re-routed into the overlay and the live attributes restored to the
+ * snapshot. New blocks (no snapshot entry) are tracked but not intercepted —
+ * inserting a block in Suggest mode is currently a real edit, not a
+ * suggestion. Removed blocks are dropped from the snapshot.
+ *
+ * @return {null} Renders nothing.
+ */
+export default function SuggestionStoreInterceptor() {
+ const { entries, captureBaseline, setOverlayAttributes } =
+ useSuggestionOverlay();
+ const registry = useRegistry();
+
+ const isSuggestMode = useSelect(
+ ( select ) =>
+ select( EDITOR_STORE_NAME ).getEditorIntent() === SUGGEST_INTENT,
+ []
+ );
+
+ // Mutable references read from inside the subscribe callback. Using refs
+ // avoids resubscribing on every entries / overlay change.
+ const entriesRef = useRef( entries );
+ entriesRef.current = entries;
+
+ const captureBaselineRef = useRef( captureBaseline );
+ captureBaselineRef.current = captureBaseline;
+
+ const setOverlayAttributesRef = useRef( setOverlayAttributes );
+ setOverlayAttributesRef.current = setOverlayAttributes;
+
+ useEffect( () => {
+ if ( ! isSuggestMode ) {
+ return undefined;
+ }
+
+ const blockEditor = registry.select( BLOCK_EDITOR_STORE_NAME );
+ const blockEditorDispatch = registry.dispatch(
+ BLOCK_EDITOR_STORE_NAME
+ );
+ if ( ! blockEditor || ! blockEditorDispatch ) {
+ return undefined;
+ }
+
+ // Snapshot of every block's attributes at the moment Suggest mode
+ // activated. New blocks added during the session are slotted in as
+ // they appear; mutations on existing blocks are reverted + overlaid.
+ const snapshot = new Map();
+ const seedClientIds = blockEditor.getClientIdsWithDescendants?.() ?? [];
+ for ( const clientId of seedClientIds ) {
+ snapshot.set(
+ clientId,
+ blockEditor.getBlockAttributes( clientId )
+ );
+ }
+
+ // Set true while we're calling `updateBlockAttributes` to revert a
+ // detected mutation, so the resulting subscribe fire doesn't loop.
+ let isReverting = false;
+
+ const unsubscribe = registry.subscribe( () => {
+ if ( isReverting ) {
+ return;
+ }
+
+ const liveClientIds =
+ blockEditor.getClientIdsWithDescendants?.() ?? [];
+ const live = new Set( liveClientIds );
+
+ for ( const clientId of liveClientIds ) {
+ let previous = snapshot.get( clientId );
+ const current = blockEditor.getBlockAttributes( clientId );
+
+ if ( previous === undefined ) {
+ // New block (inserted after Suggest activated). Track it
+ // but don't intercept.
+ snapshot.set( clientId, current );
+ continue;
+ }
+
+ if ( previous === current ) {
+ // Block-editor preserves attribute object identity for
+ // untouched blocks, so this short-circuit covers the
+ // common case cheaply.
+ continue;
+ }
+
+ // Programmatic linkage updates (e.g. `metadata.noteId` set by
+ // the suggestion provider after creating the note comment)
+ // must persist on the live block. Folding them into the
+ // snapshot before diffing makes them invisible to the diff,
+ // keeps the revert payload from clobbering them, and prevents
+ // them from leaking into the user's overlay.
+ const adopted = adoptSystemMetadata( previous, current );
+ if ( adopted !== previous ) {
+ snapshot.set( clientId, adopted );
+ previous = adopted;
+ }
+
+ const delta = diffAttributes( previous, current );
+ if ( ! delta ) {
+ snapshot.set( clientId, current );
+ continue;
+ }
+
+ // Capture a baseline if one isn't already set. The HOC's
+ // own captureBaseline only fires for `setAttributes` calls;
+ // for store-level mutations we have to seed one here.
+ const overlayEntries = entriesRef.current;
+ if ( ! overlayEntries[ clientId ] ) {
+ const block = blockEditor.getBlock?.( clientId );
+ captureBaselineRef.current(
+ clientId,
+ block?.name ?? '',
+ previous
+ );
+ }
+
+ // Route the changes into the overlay so the user still sees
+ // their edit, then revert the underlying store back to the
+ // snapshot so the post itself isn't actually modified. System
+ // metadata is filtered out of the overlay payload — it isn't
+ // a user edit, and `delta.restore` already preserves it.
+ const overlayChanged = stripSystemMetadata( delta.changed );
+ if ( Object.keys( overlayChanged ).length > 0 ) {
+ setOverlayAttributesRef.current( clientId, overlayChanged );
+ }
+
+ isReverting = true;
+ try {
+ blockEditorDispatch.updateBlockAttributes(
+ clientId,
+ delta.restore
+ );
+ } finally {
+ isReverting = false;
+ }
+
+ // The snapshot reflects the (now-restored) baseline for this
+ // block; do NOT update it to `current` here.
+ }
+
+ // Drop snapshot entries for blocks that were removed.
+ if ( snapshot.size > liveClientIds.length ) {
+ for ( const clientId of snapshot.keys() ) {
+ if ( ! live.has( clientId ) ) {
+ snapshot.delete( clientId );
+ }
+ }
+ }
+ }, BLOCK_EDITOR_STORE_NAME );
+
+ return unsubscribe;
+ }, [ isSuggestMode, registry ] );
+
+ return null;
+}
+
+export {
+ diffAttributes,
+ shallowAttributeEquals,
+ adoptSystemMetadata,
+ stripSystemMetadata,
+};
diff --git a/packages/editor/src/components/suggestion-mode/test/overlay-context.js b/packages/editor/src/components/suggestion-mode/test/overlay-context.js
new file mode 100644
index 00000000000000..7961a6d2ef28e6
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/test/overlay-context.js
@@ -0,0 +1,126 @@
+/**
+ * Internal dependencies
+ */
+import { overlayReducer } from '../overlay-context';
+
+describe( 'overlayReducer', () => {
+ const CLIENT_ID = 'abc-123';
+ const INITIAL = Object.freeze( {} );
+
+ it( 'captures a baseline once per client id', () => {
+ const afterFirst = overlayReducer( INITIAL, {
+ type: 'CAPTURE_BASELINE',
+ clientId: CLIENT_ID,
+ blockName: 'core/paragraph',
+ attributes: { content: 'Hello' },
+ } );
+ expect( afterFirst[ CLIENT_ID ] ).toEqual( {
+ blockName: 'core/paragraph',
+ baselineAttributes: { content: 'Hello' },
+ overlayAttributes: {},
+ } );
+
+ const afterSecond = overlayReducer( afterFirst, {
+ type: 'CAPTURE_BASELINE',
+ clientId: CLIENT_ID,
+ blockName: 'core/paragraph',
+ attributes: { content: 'CHANGED' },
+ } );
+ expect( afterSecond ).toBe( afterFirst );
+ } );
+
+ it( 'merges overlay attributes over an existing entry', () => {
+ const withBaseline = overlayReducer( INITIAL, {
+ type: 'CAPTURE_BASELINE',
+ clientId: CLIENT_ID,
+ blockName: 'core/paragraph',
+ attributes: { content: 'A', level: 2 },
+ } );
+ const withOverlay = overlayReducer( withBaseline, {
+ type: 'SET_OVERLAY_ATTRIBUTES',
+ clientId: CLIENT_ID,
+ attributes: { content: 'B' },
+ } );
+ expect( withOverlay[ CLIENT_ID ].overlayAttributes ).toEqual( {
+ content: 'B',
+ } );
+ expect( withOverlay[ CLIENT_ID ].baselineAttributes ).toEqual( {
+ content: 'A',
+ level: 2,
+ } );
+
+ const updated = overlayReducer( withOverlay, {
+ type: 'SET_OVERLAY_ATTRIBUTES',
+ clientId: CLIENT_ID,
+ attributes: { level: 3 },
+ } );
+ expect( updated[ CLIENT_ID ].overlayAttributes ).toEqual( {
+ content: 'B',
+ level: 3,
+ } );
+ } );
+
+ it( 'ignores overlay writes without a captured baseline', () => {
+ const next = overlayReducer( INITIAL, {
+ type: 'SET_OVERLAY_ATTRIBUTES',
+ clientId: CLIENT_ID,
+ attributes: { content: 'Nope' },
+ } );
+ expect( next ).toBe( INITIAL );
+ } );
+
+ it( 'removes an entry on clear', () => {
+ const withEntry = overlayReducer( INITIAL, {
+ type: 'CAPTURE_BASELINE',
+ clientId: CLIENT_ID,
+ blockName: 'core/paragraph',
+ attributes: {},
+ } );
+ const cleared = overlayReducer( withEntry, {
+ type: 'CLEAR_OVERLAY',
+ clientId: CLIENT_ID,
+ } );
+ expect( cleared ).toEqual( {} );
+ expect( cleared ).not.toBe( withEntry );
+ } );
+
+ it( 'returns the same reference for unknown actions', () => {
+ const next = overlayReducer( INITIAL, { type: 'UNKNOWN' } );
+ expect( next ).toBe( INITIAL );
+ } );
+
+ it( 'prunes entries whose clientId is no longer live', () => {
+ const state = {
+ 'alive-1': {
+ blockName: 'core/paragraph',
+ baselineAttributes: {},
+ overlayAttributes: { content: 'hi' },
+ },
+ 'orphan-1': {
+ blockName: 'core/paragraph',
+ baselineAttributes: {},
+ overlayAttributes: { content: 'gone' },
+ },
+ };
+ const next = overlayReducer( state, {
+ type: 'PRUNE_ORPHANS',
+ liveClientIds: new Set( [ 'alive-1' ] ),
+ } );
+ expect( Object.keys( next ) ).toEqual( [ 'alive-1' ] );
+ } );
+
+ it( 'returns the same reference when no orphans are present', () => {
+ const state = {
+ 'alive-1': {
+ blockName: 'core/paragraph',
+ baselineAttributes: {},
+ overlayAttributes: {},
+ },
+ };
+ const next = overlayReducer( state, {
+ type: 'PRUNE_ORPHANS',
+ liveClientIds: new Set( [ 'alive-1', 'other' ] ),
+ } );
+ expect( next ).toBe( state );
+ } );
+} );
diff --git a/packages/editor/src/components/suggestion-mode/test/provider.js b/packages/editor/src/components/suggestion-mode/test/provider.js
new file mode 100644
index 00000000000000..4540e4d2cf1519
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/test/provider.js
@@ -0,0 +1,113 @@
+/**
+ * Internal dependencies
+ */
+import {
+ operationsFromOverlay,
+ payloadByteLength,
+ PAYLOAD_MAX_BYTES,
+} from '../provider';
+
+describe( 'operationsFromOverlay', () => {
+ it( 'emits one attribute-set op per changed key', () => {
+ const ops = operationsFromOverlay(
+ { content: 'Hello', level: 2 },
+ { content: 'Hi', level: 3 }
+ );
+ expect( ops ).toEqual( [
+ {
+ type: 'attribute-set',
+ attribute: 'content',
+ before: 'Hello',
+ after: 'Hi',
+ },
+ {
+ type: 'attribute-set',
+ attribute: 'level',
+ before: 2,
+ after: 3,
+ },
+ ] );
+ } );
+
+ it( 'skips attributes that equal their baseline', () => {
+ const ops = operationsFromOverlay(
+ { content: 'Same', level: 2 },
+ { content: 'Same', level: 3 }
+ );
+ expect( ops ).toEqual( [
+ {
+ type: 'attribute-set',
+ attribute: 'level',
+ before: 2,
+ after: 3,
+ },
+ ] );
+ } );
+
+ it( 'deep-compares object-valued attributes', () => {
+ const ops = operationsFromOverlay(
+ { style: { typography: { fontSize: '16px' } } },
+ { style: { typography: { fontSize: '16px' } } }
+ );
+ expect( ops ).toEqual( [] );
+ } );
+
+ it( 'is insensitive to key order in object-valued attributes', () => {
+ // `style` re-emitted with reordered keys must not appear as a
+ // changed attribute. A naive JSON.stringify compare would flag it.
+ const ops = operationsFromOverlay(
+ { style: { typography: { fontSize: '16px' }, color: 'red' } },
+ { style: { color: 'red', typography: { fontSize: '16px' } } }
+ );
+ expect( ops ).toEqual( [] );
+ } );
+
+ it( 'compares arrays element-wise', () => {
+ expect(
+ operationsFromOverlay(
+ { classes: [ 'a', 'b' ] },
+ { classes: [ 'a', 'b' ] }
+ )
+ ).toEqual( [] );
+ const ops = operationsFromOverlay(
+ { classes: [ 'a', 'b' ] },
+ { classes: [ 'b', 'a' ] }
+ );
+ expect( ops ).toHaveLength( 1 );
+ expect( ops[ 0 ].attribute ).toBe( 'classes' );
+ } );
+
+ it( 'captures a null baseline when the attribute is new', () => {
+ const ops = operationsFromOverlay( {}, { url: 'https://x.test' } );
+ expect( ops ).toEqual( [
+ {
+ type: 'attribute-set',
+ attribute: 'url',
+ before: null,
+ after: 'https://x.test',
+ },
+ ] );
+ } );
+
+ it( 'returns an empty array for an empty overlay', () => {
+ expect( operationsFromOverlay( { a: 1 }, {} ) ).toEqual( [] );
+ expect( operationsFromOverlay( { a: 1 }, null ) ).toEqual( [] );
+ } );
+} );
+
+describe( 'payloadByteLength', () => {
+ it( 'measures ASCII payload byte length', () => {
+ // {"a":"hello"} is 13 bytes.
+ expect( payloadByteLength( { a: 'hello' } ) ).toBe( 13 );
+ } );
+
+ it( 'counts multi-byte characters by UTF-8 byte length', () => {
+ // {"a":"€"} = 8 ASCII bytes + 3 bytes for the euro sign = 11.
+ expect( payloadByteLength( { a: '€' } ) ).toBe( 11 );
+ } );
+
+ it( 'exposes a numeric size cap', () => {
+ expect( PAYLOAD_MAX_BYTES ).toBeGreaterThan( 0 );
+ expect( typeof PAYLOAD_MAX_BYTES ).toBe( 'number' );
+ } );
+} );
diff --git a/packages/editor/src/components/suggestion-mode/test/store-interceptor.js b/packages/editor/src/components/suggestion-mode/test/store-interceptor.js
new file mode 100644
index 00000000000000..2bf87a68088856
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/test/store-interceptor.js
@@ -0,0 +1,359 @@
+/**
+ * External dependencies
+ */
+import { render, act } from '@testing-library/react';
+
+/**
+ * WordPress dependencies
+ */
+import { createRegistry, RegistryProvider } from '@wordpress/data';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { store as noticesStore } from '@wordpress/notices';
+import { store as preferencesStore } from '@wordpress/preferences';
+import {
+ createBlock,
+ registerBlockType,
+ unregisterBlockType,
+ getBlockTypes,
+} from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import SuggestionStoreInterceptor, {
+ diffAttributes,
+ shallowAttributeEquals,
+ adoptSystemMetadata,
+ stripSystemMetadata,
+} from '../store-interceptor';
+import {
+ SuggestionOverlayProvider,
+ useSuggestionOverlay,
+} from '../overlay-context';
+import { store as editorStore } from '../../../store';
+
+describe( 'shallowAttributeEquals', () => {
+ it( 'treats reference-equal values as equal', () => {
+ const obj = { a: 1 };
+ expect( shallowAttributeEquals( obj, obj ) ).toBe( true );
+ } );
+
+ it( 'returns true for primitives that match', () => {
+ expect( shallowAttributeEquals( 1, 1 ) ).toBe( true );
+ expect( shallowAttributeEquals( 'a', 'a' ) ).toBe( true );
+ } );
+
+ it( 'returns false when only one side is null/undefined', () => {
+ expect( shallowAttributeEquals( null, {} ) ).toBe( false );
+ expect( shallowAttributeEquals( undefined, '' ) ).toBe( false );
+ } );
+
+ it( 'compares plain objects by structure', () => {
+ expect( shallowAttributeEquals( { a: 1, b: 2 }, { a: 1, b: 2 } ) ).toBe(
+ true
+ );
+ } );
+} );
+
+describe( 'diffAttributes', () => {
+ it( 'returns null when attributes are unchanged', () => {
+ expect(
+ diffAttributes( { content: 'a' }, { content: 'a' } )
+ ).toBeNull();
+ } );
+
+ it( 'detects changed values and emits matching restore', () => {
+ expect( diffAttributes( { level: 2 }, { level: 3 } ) ).toEqual( {
+ changed: { level: 3 },
+ restore: { level: 2 },
+ } );
+ } );
+
+ it( 'detects added keys (no previous value to restore)', () => {
+ expect( diffAttributes( {}, { url: 'https://example.test' } ) ).toEqual(
+ {
+ changed: { url: 'https://example.test' },
+ restore: { url: undefined },
+ }
+ );
+ } );
+
+ it( 'detects removed keys and includes them in restore', () => {
+ expect( diffAttributes( { align: 'center' }, {} ) ).toEqual( {
+ changed: { align: undefined },
+ restore: { align: 'center' },
+ } );
+ } );
+
+ it( 'collects multiple changes in one delta', () => {
+ const delta = diffAttributes(
+ { level: 2, content: 'Hi' },
+ { level: 3, content: 'Hi' }
+ );
+ expect( delta ).toEqual( {
+ changed: { level: 3 },
+ restore: { level: 2 },
+ } );
+ } );
+} );
+
+describe( 'adoptSystemMetadata', () => {
+ it( 'returns the same reference when no system metadata key changed', () => {
+ const previous = { content: 'Hi', metadata: { name: 'Greeting' } };
+ const current = { content: 'Hi', metadata: { name: 'Greeting' } };
+ expect( adoptSystemMetadata( previous, current ) ).toBe( previous );
+ } );
+
+ it( 'folds a newly-added noteId into the snapshot', () => {
+ const previous = { content: 'Hi', metadata: {} };
+ const current = { content: 'Hi', metadata: { noteId: 42 } };
+ const adopted = adoptSystemMetadata( previous, current );
+ expect( adopted ).not.toBe( previous );
+ expect( adopted ).toEqual( {
+ content: 'Hi',
+ metadata: { noteId: 42 },
+ } );
+ } );
+
+ it( 'folds a changed noteId into the snapshot', () => {
+ const previous = { metadata: { noteId: 1 } };
+ const current = { metadata: { noteId: 2 } };
+ expect( adoptSystemMetadata( previous, current ) ).toEqual( {
+ metadata: { noteId: 2 },
+ } );
+ } );
+
+ it( 'removes noteId when current no longer has one', () => {
+ const previous = { metadata: { noteId: 7, name: 'Keep' } };
+ const current = { metadata: { name: 'Keep' } };
+ expect( adoptSystemMetadata( previous, current ) ).toEqual( {
+ metadata: { name: 'Keep' },
+ } );
+ } );
+
+ it( 'preserves user-managed metadata keys when adopting noteId', () => {
+ const previous = { metadata: { name: 'Greeting' } };
+ const current = { metadata: { name: 'Greeting', noteId: 9 } };
+ expect( adoptSystemMetadata( previous, current ) ).toEqual( {
+ metadata: { name: 'Greeting', noteId: 9 },
+ } );
+ } );
+
+ it( 'never folds non-system metadata keys', () => {
+ const previous = { metadata: { bindings: { a: 1 } } };
+ const current = { metadata: { bindings: { b: 2 } } };
+ // `bindings` is a user-managed key — should be left alone for the
+ // regular diff to handle.
+ expect( adoptSystemMetadata( previous, current ) ).toBe( previous );
+ } );
+} );
+
+describe( 'SuggestionStoreInterceptor (integration)', () => {
+ const TEST_BLOCK_NAME = 'core/test-suggestion-block';
+
+ beforeAll( () => {
+ registerBlockType( TEST_BLOCK_NAME, {
+ apiVersion: 3,
+ attributes: {
+ content: { type: 'string', default: '' },
+ metadata: { type: 'object' },
+ },
+ save: () => null,
+ category: 'text',
+ title: 'Test Suggestion Block',
+ } );
+ } );
+
+ afterAll( () => {
+ getBlockTypes().forEach( ( block ) =>
+ unregisterBlockType( block.name )
+ );
+ } );
+
+ function setup() {
+ const registry = createRegistry();
+ registry.register( noticesStore );
+ // `preferencesStore` is required by `setEditorIntent` on branches
+ // where the intent is persisted as a preference; later branches
+ // switched to in-memory session state and ignore it. Registering
+ // it here keeps the test portable across the stacked PR set.
+ registry.register( preferencesStore );
+ registry.register( blockEditorStore );
+ registry.register( editorStore );
+ registry.dispatch( editorStore ).setEditorIntent( 'suggest' );
+
+ const block = createBlock( TEST_BLOCK_NAME, { content: 'Hello' } );
+ registry.dispatch( blockEditorStore ).resetBlocks( [ block ] );
+
+ let overlayHandle;
+ function CaptureOverlay() {
+ overlayHandle = useSuggestionOverlay();
+ return null;
+ }
+
+ const wrapper = ( { children } ) => (
+
+
+ { children }
+
+
+ );
+
+ render(
+ <>
+
+
+ >,
+ { wrapper }
+ );
+
+ return {
+ registry,
+ clientId: block.clientId,
+ getOverlay: () => overlayHandle,
+ };
+ }
+
+ async function flushSubscribers() {
+ // `registry.subscribe` callbacks are scheduled asynchronously after
+ // dispatches; one microtask flush is enough for the interceptor's
+ // post-dispatch reaction to run.
+ await act( async () => {
+ await Promise.resolve();
+ } );
+ }
+
+ it( 'preserves a programmatic metadata.noteId update on the live block', async () => {
+ // This is the regression scenario from the suggestion provider:
+ // after creating a note comment it calls
+ // `updateBlockAttributes(clientId, { metadata: { noteId } })` to
+ // link the block to its note. Without the fix, the interceptor
+ // reverted that update and the note appeared orphaned in the sidebar.
+ const { registry, clientId, getOverlay } = setup();
+
+ await act( async () => {
+ registry
+ .dispatch( blockEditorStore )
+ .updateBlockAttributes( clientId, {
+ metadata: { noteId: 42 },
+ } );
+ } );
+ await flushSubscribers();
+
+ const liveAttributes = registry
+ .select( blockEditorStore )
+ .getBlockAttributes( clientId );
+
+ expect( liveAttributes?.metadata?.noteId ).toBe( 42 );
+ // The system update must NOT leak into the overlay.
+ expect(
+ getOverlay().entries[ clientId ]?.overlayAttributes?.metadata
+ ).toBeUndefined();
+ } );
+
+ it( 'preserves metadata.noteId while still intercepting other attribute changes', async () => {
+ const { registry, clientId, getOverlay } = setup();
+
+ // First, link the block to a note (as the suggestion provider would).
+ await act( async () => {
+ registry
+ .dispatch( blockEditorStore )
+ .updateBlockAttributes( clientId, { metadata: { noteId: 7 } } );
+ } );
+ await flushSubscribers();
+
+ // Then simulate a direct user-driven mutation of `content` (the kind
+ // of bypass the interceptor was added for, e.g. a block-switcher
+ // variation picker calling `updateBlockAttributes` directly).
+ await act( async () => {
+ registry
+ .dispatch( blockEditorStore )
+ .updateBlockAttributes( clientId, { content: 'Edited' } );
+ } );
+ await flushSubscribers();
+
+ const liveAttributes = registry
+ .select( blockEditorStore )
+ .getBlockAttributes( clientId );
+
+ // noteId is preserved on the live block (system linkage).
+ expect( liveAttributes?.metadata?.noteId ).toBe( 7 );
+ // The user content edit is reverted to the baseline.
+ expect( liveAttributes?.content ).toBe( 'Hello' );
+ // The user content edit IS captured in the overlay.
+ expect(
+ getOverlay().entries[ clientId ]?.overlayAttributes?.content
+ ).toBe( 'Edited' );
+ // noteId never appears in the overlay.
+ expect(
+ getOverlay().entries[ clientId ]?.overlayAttributes?.metadata
+ ).toBeUndefined();
+ } );
+
+ it( 'preserves metadata.noteId when reverting a same-tick combined mutation', async () => {
+ const { registry, clientId, getOverlay } = setup();
+
+ // A direct dispatch that touches both `content` (user-style) and
+ // `metadata.noteId` (system-style) at the same time. The interceptor
+ // must keep the noteId on the live block while still routing the
+ // content change into the overlay.
+ await act( async () => {
+ registry
+ .dispatch( blockEditorStore )
+ .updateBlockAttributes( clientId, {
+ content: 'Edited',
+ metadata: { noteId: 11 },
+ } );
+ } );
+ await flushSubscribers();
+
+ const liveAttributes = registry
+ .select( blockEditorStore )
+ .getBlockAttributes( clientId );
+
+ expect( liveAttributes?.metadata?.noteId ).toBe( 11 );
+ expect( liveAttributes?.content ).toBe( 'Hello' );
+ expect(
+ getOverlay().entries[ clientId ]?.overlayAttributes?.content
+ ).toBe( 'Edited' );
+ } );
+} );
+
+describe( 'stripSystemMetadata', () => {
+ it( 'returns the payload unchanged when there is no metadata', () => {
+ const payload = { content: 'Hi' };
+ expect( stripSystemMetadata( payload ) ).toBe( payload );
+ } );
+
+ it( 'returns the payload unchanged when metadata has no system keys', () => {
+ const payload = { metadata: { name: 'Section' } };
+ expect( stripSystemMetadata( payload ) ).toBe( payload );
+ } );
+
+ it( 'drops noteId from metadata while keeping other keys', () => {
+ const payload = { metadata: { noteId: 5, name: 'Section' } };
+ expect( stripSystemMetadata( payload ) ).toEqual( {
+ metadata: { name: 'Section' },
+ } );
+ } );
+
+ it( 'drops the metadata key entirely when only system keys remain', () => {
+ const payload = { content: 'Hi', metadata: { noteId: 5 } };
+ expect( stripSystemMetadata( payload ) ).toEqual( {
+ content: 'Hi',
+ } );
+ } );
+
+ it( 'preserves other top-level keys', () => {
+ const payload = {
+ content: 'Hi',
+ level: 3,
+ metadata: { noteId: 5, name: 'Heading' },
+ };
+ expect( stripSystemMetadata( payload ) ).toEqual( {
+ content: 'Hi',
+ level: 3,
+ metadata: { name: 'Heading' },
+ } );
+ } );
+} );
diff --git a/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js b/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js
new file mode 100644
index 00000000000000..70e9683f36ea86
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js
@@ -0,0 +1,272 @@
+/**
+ * External dependencies
+ */
+import { render, screen, act, fireEvent } from '@testing-library/react';
+
+/**
+ * WordPress dependencies
+ */
+import { createRegistry, RegistryProvider } from '@wordpress/data';
+import { store as preferencesStore } from '@wordpress/preferences';
+
+/**
+ * Internal dependencies
+ */
+import withSuggestionOverlay, {
+ mergeOverlayAttributes,
+} from '../with-suggestion-overlay';
+import {
+ SuggestionOverlayProvider,
+ useSuggestionOverlay,
+} from '../overlay-context';
+import { store as editorStore } from '../../../store';
+
+function renderWithProviders( ui, { intent = 'edit' } = {} ) {
+ const registry = createRegistry();
+ registry.register( preferencesStore );
+ registry.register( editorStore );
+ registry.dispatch( preferencesStore ).set( 'core', 'editorIntent', intent );
+
+ const wrapper = ( { children } ) => (
+
+ { children }
+
+ );
+
+ return {
+ registry,
+ ...render( ui, { wrapper } ),
+ };
+}
+
+// Minimal block component that exposes its received attributes and
+// calls setAttributes when its button is clicked.
+function FakeBlock( { attributes, setAttributes } ) {
+ return (
+ <>
+ { attributes?.content ?? '' }
+
+ >
+ );
+}
+
+const Wrapped = withSuggestionOverlay( FakeBlock );
+
+describe( 'withSuggestionOverlay', () => {
+ it( 'passes through unchanged in Edit intent', () => {
+ const setAttributes = jest.fn();
+ renderWithProviders(
+
+ );
+
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent( 'Hello' );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ content: 'proposed',
+ } );
+ } );
+
+ it( 'diverts setAttributes into the overlay in Suggest intent', () => {
+ const setAttributes = jest.fn();
+ renderWithProviders(
+ ,
+ { intent: 'suggest' }
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) );
+
+ // Real setAttributes is never called; block renders merged value.
+ expect( setAttributes ).not.toHaveBeenCalled();
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent(
+ 'proposed'
+ );
+ } );
+
+ it( 'merges overlay on top of real attributes for rendering', () => {
+ const setAttributes = jest.fn();
+ const { rerender } = renderWithProviders(
+ ,
+ { intent: 'suggest' }
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) );
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent(
+ 'proposed'
+ );
+
+ // Real attributes update (e.g., from RTC sync). Overlay wins on
+ // overlapping keys; non-overlapping keys reflect the new real value.
+ rerender(
+
+ );
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent(
+ 'proposed'
+ );
+ } );
+
+ it( 'passes through in View intent — no overlay, no diversion', () => {
+ const setAttributes = jest.fn();
+ renderWithProviders(
+ ,
+ { intent: 'view' }
+ );
+
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent(
+ 'Untouched'
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) );
+
+ // In view intent the HOC is a pass-through, so the real
+ // setAttributes is invoked and the overlay is never used.
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ content: 'proposed',
+ } );
+ } );
+
+ it( 're-captures baseline when overlay is cleared then re-edited', () => {
+ // Regression: after Submit/Discard clears the overlay entry, a
+ // later edit must create a new baseline + overlay rather than
+ // silently no-oping.
+ let clearOverlayHandle;
+ function Harness() {
+ const { clearOverlay } = useSuggestionOverlay();
+ clearOverlayHandle = clearOverlay;
+ return null;
+ }
+
+ const setAttributes = jest.fn();
+ renderWithProviders(
+ <>
+
+
+ >,
+ { intent: 'suggest' }
+ );
+
+ // First edit — creates overlay.
+ fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) );
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent(
+ 'proposed'
+ );
+
+ // Simulate Submit/Discard clearing the overlay.
+ act( () => {
+ clearOverlayHandle( 'a' );
+ } );
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent( 'Hello' );
+
+ // Second edit — must capture a new baseline and record the
+ // overlay, not silently no-op.
+ fireEvent.click( screen.getByRole( 'button', { name: 'edit' } ) );
+ expect( screen.getByTestId( 'content' ) ).toHaveTextContent(
+ 'proposed'
+ );
+ // The real setAttributes is still never invoked in suggest mode.
+ expect( setAttributes ).not.toHaveBeenCalled();
+ } );
+} );
+
+describe( 'mergeOverlayAttributes', () => {
+ it( 'returns base unchanged when there is no overlay', () => {
+ const base = { content: 'Hello', level: 2 };
+ expect( mergeOverlayAttributes( base, null ) ).toBe( base );
+ expect( mergeOverlayAttributes( base, undefined ) ).toBe( base );
+ } );
+
+ it( 'replaces primitive overlay values wholesale', () => {
+ expect(
+ mergeOverlayAttributes(
+ { content: 'Hello', level: 2 },
+ { level: 3 }
+ )
+ ).toEqual( { content: 'Hello', level: 3 } );
+ } );
+
+ it( 'one-level merges the style attribute so untouched fields survive', () => {
+ expect(
+ mergeOverlayAttributes(
+ {
+ style: {
+ typography: { fontSize: '16px' },
+ color: 'red',
+ },
+ },
+ { style: { color: 'blue' } }
+ )
+ ).toEqual( {
+ style: {
+ typography: { fontSize: '16px' },
+ color: 'blue',
+ },
+ } );
+ } );
+
+ it( 'one-level merges metadata so e.g. noteId survives a name change', () => {
+ expect(
+ mergeOverlayAttributes(
+ { metadata: { name: 'Block A', noteId: 42 } },
+ { metadata: { name: 'Block B' } }
+ )
+ ).toEqual( {
+ metadata: { name: 'Block B', noteId: 42 },
+ } );
+ } );
+
+ it( 'replaces array-valued attributes wholesale (no merge)', () => {
+ expect(
+ mergeOverlayAttributes(
+ { classes: [ 'a', 'b' ] },
+ { classes: [ 'c' ] }
+ )
+ ).toEqual( { classes: [ 'c' ] } );
+ } );
+
+ it( 'replaces non-deep-merge object attributes wholesale', () => {
+ // `metadata` and `style` are deep-merged; everything else is
+ // replaced even if it happens to be an object.
+ expect(
+ mergeOverlayAttributes(
+ { custom: { nested: 'old' } },
+ { custom: { other: 'new' } }
+ )
+ ).toEqual( { custom: { other: 'new' } } );
+ } );
+} );
diff --git a/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js b/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js
new file mode 100644
index 00000000000000..a1a217a62f43d8
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js
@@ -0,0 +1,153 @@
+/**
+ * WordPress dependencies
+ */
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { useSelect } from '@wordpress/data';
+import { useCallback, useMemo, useRef } from '@wordpress/element';
+import { addFilter } from '@wordpress/hooks';
+
+/**
+ * Internal dependencies
+ */
+import { useSuggestionOverlay } from './overlay-context';
+import { EDITOR_STORE_NAME, SUGGEST_INTENT } from './constants';
+
+/**
+ * Attribute keys whose values are known to be object-valued and therefore
+ * need a one-level-deep merge so the overlay preserves untouched fields.
+ * Other attributes are replaced wholesale (which matches `setAttributes`
+ * semantics for primitive and array values).
+ */
+const DEEP_MERGE_KEYS = new Set( [ 'style', 'metadata' ] );
+
+function mergeOverlayAttributes( base, overlay ) {
+ if ( ! overlay ) {
+ return base;
+ }
+ const merged = { ...base };
+ for ( const [ key, value ] of Object.entries( overlay ) ) {
+ if (
+ DEEP_MERGE_KEYS.has( key ) &&
+ value &&
+ typeof value === 'object' &&
+ ! Array.isArray( value ) &&
+ merged[ key ] &&
+ typeof merged[ key ] === 'object' &&
+ ! Array.isArray( merged[ key ] )
+ ) {
+ merged[ key ] = { ...merged[ key ], ...value };
+ } else {
+ merged[ key ] = value;
+ }
+ }
+ return merged;
+}
+
+/**
+ * Inner renderer that owns the suggestion overlay hooks. Only mounted when
+ * the editor is in `suggest` intent, so the overlay's context lookup,
+ * refs, and memoized merge don't run on every `BlockEdit` render for every
+ * block across the entire editor when suggestions are inactive. This split
+ * matters for large documents — in Edit/View intent the outer wrapper
+ * executes a single `useSelect` and renders the original `BlockEdit`
+ * untouched.
+ *
+ * @param {Object} args Arguments.
+ * @param {import('react').ComponentType} args.BlockEdit Wrapped edit component.
+ * @param {Object} args.props Props to forward to `BlockEdit`.
+ */
+function SuggestingBlockEdit( { BlockEdit, props } ) {
+ const { clientId, name, attributes } = props;
+ const { entries, captureBaseline, setOverlayAttributes } =
+ useSuggestionOverlay();
+
+ // Track the latest attributes via a ref so the wrapped `setAttributes`
+ // callback remains stable. Blocks sometimes invoke `setAttributes` from
+ // effects keyed on this reference.
+ const attributesRef = useRef( attributes );
+ attributesRef.current = attributes;
+
+ const overlayAttributes = entries[ clientId ]?.overlayAttributes ?? null;
+
+ // Does an overlay entry currently exist for this block? This is the
+ // source of truth; `captureBaseline` only creates an entry when there
+ // isn't one, so we can skip the dispatch when we already know there is.
+ // Relying on a local ref was fragile — it didn't reset after the entry
+ // was cleared (orphan prune, intent-switch).
+ const entryExists = !! entries[ clientId ];
+
+ const wrappedSetAttributes = useCallback(
+ ( nextAttributes ) => {
+ if ( ! entryExists ) {
+ captureBaseline( clientId, name, attributesRef.current );
+ }
+ setOverlayAttributes( clientId, nextAttributes );
+ },
+ [ clientId, name, captureBaseline, setOverlayAttributes, entryExists ]
+ );
+
+ const mergedAttributes = useMemo(
+ () => mergeOverlayAttributes( attributes, overlayAttributes ),
+ [ attributes, overlayAttributes ]
+ );
+
+ return (
+
+ );
+}
+
+/**
+ * HOC that diverts block edits to the suggestion overlay when the editor is
+ * in the `suggest` intent. The block's real attributes are never mutated;
+ * overlay attributes are merged into the `attributes` prop for rendering so
+ * the user sees their in-progress change, but the block-editor store stays
+ * at the baseline until the suggestion is committed.
+ *
+ * In any other intent the HOC is a pass-through and adds only a single
+ * `useSelect` call per block.
+ */
+const withSuggestionOverlay = createHigherOrderComponent(
+ ( BlockEdit ) =>
+ function BlockEditWithSuggestionOverlay( props ) {
+ const isSuggestMode = useSelect(
+ ( select ) =>
+ select( EDITOR_STORE_NAME ).getEditorIntent() ===
+ SUGGEST_INTENT,
+ []
+ );
+
+ if ( ! isSuggestMode ) {
+ return ;
+ }
+
+ return (
+
+ );
+ },
+ 'withSuggestionOverlay'
+);
+
+let filterRegistered = false;
+
+/**
+ * Register the overlay filter. Idempotent — safe to call multiple times
+ * (hot reload, dynamic imports).
+ */
+export function registerSuggestionOverlayFilter() {
+ if ( filterRegistered ) {
+ return;
+ }
+ filterRegistered = true;
+ addFilter(
+ 'editor.BlockEdit',
+ 'core/editor/suggestion-mode-overlay',
+ withSuggestionOverlay
+ );
+}
+
+export { mergeOverlayAttributes };
+export default withSuggestionOverlay;
diff --git a/test/e2e/specs/editor/various/suggestion-mode.spec.js b/test/e2e/specs/editor/various/suggestion-mode.spec.js
new file mode 100644
index 00000000000000..2b7fa266d97f8d
--- /dev/null
+++ b/test/e2e/specs/editor/various/suggestion-mode.spec.js
@@ -0,0 +1,78 @@
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+async function switchIntent( page, intentLabel ) {
+ await page
+ .getByRole( 'region', { name: 'Editor top bar' } )
+ .getByRole( 'button', { name: 'Options' } )
+ .click();
+ const menuItem = page.getByRole( 'menuitemradio', {
+ name: new RegExp( `^${ intentLabel }` ),
+ } );
+ await menuItem.waitFor( { state: 'visible', timeout: 10000 } );
+ await menuItem.click();
+}
+
+test.describe( 'Suggestion mode', () => {
+ test.beforeEach( async ( { admin } ) => {
+ await admin.createNewPost();
+ } );
+
+ test( 'captures edits as an overlay without mutating the block', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: { content: 'Original content' },
+ } );
+
+ await switchIntent( page, 'Suggest' );
+
+ const paragraph = editor.canvas
+ .getByRole( 'document', { name: 'Block: Paragraph' } )
+ .first();
+ await paragraph.click();
+ await page.keyboard.press( 'End' );
+ await page.keyboard.type( ' plus suggested' );
+
+ // The rendered block reflects the overlayed suggestion.
+ await expect( paragraph ).toContainText(
+ 'Original content plus suggested'
+ );
+
+ // The serialized post content stays at the baseline — the overlay
+ // never touched the block-editor store.
+ const serialized = await editor.getEditedPostContent();
+ expect( serialized ).toContain( 'Original content' );
+ expect( serialized ).not.toContain( 'plus suggested' );
+ } );
+
+ test( 'restores baseline when switching back to Edit intent', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: { content: 'Keep as is' },
+ } );
+
+ await switchIntent( page, 'Suggest' );
+
+ const paragraph = editor.canvas
+ .getByRole( 'document', { name: 'Block: Paragraph' } )
+ .first();
+ await paragraph.click();
+ await page.keyboard.press( 'End' );
+ await page.keyboard.type( '!' );
+ await expect( paragraph ).toContainText( 'Keep as is!' );
+
+ // Switching out of Suggest intent un-merges the overlay; the block
+ // renders the real attributes again.
+ await switchIntent( page, 'Edit' );
+ await expect( paragraph ).toContainText( 'Keep as is' );
+ await expect( paragraph ).not.toContainText( 'Keep as is!' );
+ } );
+} );
diff --git a/tools/eslint/suppressions.json b/tools/eslint/suppressions.json
index f2f71cee64561f..47c01ccfeba492 100644
--- a/tools/eslint/suppressions.json
+++ b/tools/eslint/suppressions.json
@@ -1108,6 +1108,21 @@
"count": 2
}
},
+ "packages/editor/src/components/suggestion-mode/store-interceptor.js": {
+ "react-hooks/refs": {
+ "count": 3
+ }
+ },
+ "packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js": {
+ "react-hooks/globals": {
+ "count": 1
+ }
+ },
+ "packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js": {
+ "react-hooks/refs": {
+ "count": 1
+ }
+ },
"packages/editor/src/components/sync-connection-error-modal/index.tsx": {
"@wordpress/use-recommended-components": {
"count": 2