diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index 2d7679be422238..45aa4d89fbd4ca 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -10,6 +10,7 @@
- `setEditorIntent` now surfaces mode transitions with a snackbar ('You're suggesting' / 'You're editing' / 'You're viewing') alongside the existing a11y announcement.
- Suggestions: introduce a per-attribute conflict check (`hasAttributeConflict`) and a `SuggestionSummary` renderer that replaces the post-`modified_gmt` staleness compare. Adds the `wp/suggestions` architecture doc and updates the `core/editor` data reference to cover the new selectors.
- Suggestions: surface Apply / Reject actions in the collaboration sidebar via a shared `useSuggestionDecision` hook. Note headers expose icon-only Apply / Reject buttons, the note body renders the suggestion summary plus the staleness confirmation dialog, and the e2e coverage for block notes and the intent switcher is extended to the new UI.
+- Suggestions: replace the manual commit-bar with a background auto-save subsystem. Pending overlay edits flush as a `_wp_suggestion` note after a short idle window, and subsequent edits on the same block update the existing note rather than creating a new one — keeping the live block tree free of pending suggestion state.
## 14.45.0 (2026-04-29)
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index 62740b5b64aa72..74eb706f840408 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -48,7 +48,7 @@ import TemplatePartMenuItems from '../template-part-menu-items';
import MediaEditorModalMount from '../media/media-editor-modal';
import {
SuggestionOverlayProvider,
- SuggestionCommitBar,
+ SuggestionAutoSave,
SuggestionStoreInterceptor,
registerSuggestionOverlayFilter,
} from '../suggestion-mode';
@@ -479,7 +479,7 @@ export const ExperimentalEditorProvider = withRegistryProvider(
-
+
{ window?.__experimentalMediaEditorModal && (
) }
diff --git a/packages/editor/src/components/suggestion-mode/auto-save.js b/packages/editor/src/components/suggestion-mode/auto-save.js
new file mode 100644
index 00000000000000..c023452ef18782
--- /dev/null
+++ b/packages/editor/src/components/suggestion-mode/auto-save.js
@@ -0,0 +1,245 @@
+/**
+ * Background auto-save for Suggest mode.
+ *
+ * Replaces the explicit "Submit suggestion" button (`commit-bar.js` in earlier
+ * phases) with a debounced background save so a suggester sees their pending
+ * change persist on its own after a short pause in typing — the same model
+ * Google Docs uses for Suggesting mode.
+ *
+ * Behavior summary:
+ * - **Debounce**: per-block timer of `AUTOSAVE_DEBOUNCE_MS` (1500 ms).
+ * Each new edit on a block clears that block's timer and starts a new
+ * one; saves only fire during idle windows so a user typing through a
+ * paragraph generates one save, not one per keystroke.
+ * - **Per-block queue**: each `clientId` has a sequential promise chain
+ * (`queuesRef`). Saves on the same block are linked end-to-end so a
+ * slow network call doesn't race with a follow-up save and produce
+ * duplicate POSTs or out-of-order writes. Different blocks have
+ * independent queues and run concurrently.
+ * - **Create vs update vs delete**: a fresh overlay creates a new note;
+ * subsequent edits update the same note's `_wp_suggestion` meta; an
+ * overlay reverted back to baseline (user undid their suggestion)
+ * trashes the note.
+ * - **Collaboration**: the linked comment can be resolved by another peer
+ * mid-session (their accept/reject flips its `status`). Before each
+ * update we re-read the comment via core-data; if the linkage is stale
+ * we orphan it and create a fresh note. PR #75147 widened
+ * `metadata.noteId` to an array so multiple notes can coexist on a block.
+ *
+ * Refs are used heavily because:
+ * - The provider callbacks (`createSuggestion`, `updateSuggestion`,
+ * `deleteSuggestion`) are recreated whenever `postModified` changes,
+ * but in-flight saves always need the latest reference.
+ * - The save functions run inside a `setTimeout` callback that doesn't
+ * re-render, so reading the latest entries / callbacks via refs avoids
+ * stale-closure bugs without resubscribing on every overlay change.
+ */
+/**
+ * WordPress dependencies
+ */
+import { useRegistry, useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { useCallback, useEffect, useRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { useSuggestionOverlay } from './overlay-context';
+import { operationsFromOverlay, useSuggestionsProvider } from './provider';
+import { EDITOR_STORE_NAME, SUGGEST_INTENT } from './constants';
+
+const AUTOSAVE_DEBOUNCE_MS = 1500;
+
+/**
+ * Deterministic fingerprint of a list of operations so we can detect whether
+ * the overlay has changed relative to what we last synced without comparing
+ * deep object trees on every render.
+ *
+ * @param {Array} operations Operations to fingerprint.
+ * @return {string} Stable serialization.
+ */
+export function fingerprintOperations( operations ) {
+ try {
+ return JSON.stringify( operations );
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * Invisible component that auto-commits pending overlay edits to the server
+ * as note comments. Replaces the manual "Submit suggestion" button — in
+ * Suggest mode each block's pending changes are persisted after a short
+ * idle window, and subsequent edits update the same note rather than
+ * spawning a new one.
+ *
+ * @return {null} Renders nothing.
+ */
+export default function SuggestionAutoSave() {
+ const { entries, setCommentId, setSyncedOpsKey } = useSuggestionOverlay();
+ const { createSuggestion, updateSuggestion, deleteSuggestion } =
+ useSuggestionsProvider();
+ const registry = useRegistry();
+
+ const isSuggestMode = useSelect(
+ ( select ) =>
+ select( EDITOR_STORE_NAME ).getEditorIntent() === SUGGEST_INTENT,
+ []
+ );
+
+ // Refs are read from inside async callbacks so a save always operates on
+ // the latest overlay state, not the values captured when the timer was
+ // scheduled. This avoids stale-closure pitfalls (e.g. acting on a null
+ // commentId after the previous save just set one).
+ const entriesRef = useRef( entries );
+ entriesRef.current = entries;
+
+ // Provider callbacks are captured in refs for the same reason: they
+ // change reference whenever `postModified` updates, but the in-flight
+ // queue should always call the latest version.
+ const createRef = useRef( createSuggestion );
+ createRef.current = createSuggestion;
+ const updateRef = useRef( updateSuggestion );
+ updateRef.current = updateSuggestion;
+ const deleteRef = useRef( deleteSuggestion );
+ deleteRef.current = deleteSuggestion;
+ const setCommentIdRef = useRef( setCommentId );
+ setCommentIdRef.current = setCommentId;
+ const setSyncedOpsKeyRef = useRef( setSyncedOpsKey );
+ setSyncedOpsKeyRef.current = setSyncedOpsKey;
+
+ // Per-clientId debounce timer.
+ const timersRef = useRef( new Map() );
+ // Per-clientId promise chain. New saves are enqueued onto the existing
+ // chain so saves on the same block always run sequentially — no races,
+ // no duplicate POSTs, and no dropped work when the user keeps typing
+ // during a slow network call.
+ const queuesRef = useRef( new Map() );
+
+ const syncOnce = useCallback(
+ async ( clientId ) => {
+ const entry = entriesRef.current[ clientId ];
+ if ( ! entry ) {
+ return;
+ }
+ const operations = operationsFromOverlay(
+ entry.baselineAttributes,
+ entry.overlayAttributes
+ );
+ const fingerprint = fingerprintOperations( operations );
+ if ( fingerprint === entry.syncedOpsKey ) {
+ return;
+ }
+
+ // The overlay's `commentId` reference can outlive the note it
+ // points at: another collaborator may have accepted or rejected
+ // the suggestion mid-session, flipping the comment's status from
+ // `hold` to `approved`. Updating that comment would clobber its
+ // payload (and the resolved status header) with the user's new,
+ // unrelated edit. Treat a resolved link as if there were none so
+ // the next save creates a fresh note that coexists with the
+ // resolved one — this only works because PR #75147 lets a block
+ // hold multiple note ids in `metadata.noteId`.
+ let commentId = entry.commentId;
+ if ( commentId ) {
+ const linkedComment = registry
+ .select( coreStore )
+ .getEntityRecord( 'root', 'comment', commentId );
+ if ( linkedComment && linkedComment.status !== 'hold' ) {
+ commentId = null;
+ setCommentIdRef.current( clientId, null );
+ }
+ }
+
+ try {
+ if ( operations.length === 0 ) {
+ if ( commentId ) {
+ await deleteRef.current( { commentId } );
+ setCommentIdRef.current( clientId, null );
+ }
+ } else if ( commentId ) {
+ await updateRef.current( {
+ commentId,
+ blockName: entry.blockName,
+ operations,
+ } );
+ } else {
+ const saved = await createRef.current( {
+ clientId,
+ blockName: entry.blockName,
+ operations,
+ } );
+ if ( saved?.id ) {
+ setCommentIdRef.current( clientId, saved.id );
+ }
+ }
+ setSyncedOpsKeyRef.current( clientId, fingerprint );
+ } catch {
+ // Error notice is surfaced inside the provider. The next overlay
+ // change will re-enqueue a sync, so transient failures recover
+ // on their own.
+ }
+ },
+ [ registry ]
+ );
+
+ const enqueueSync = useCallback(
+ ( clientId ) => {
+ const queues = queuesRef.current;
+ const previous = queues.get( clientId ) ?? Promise.resolve();
+ const next = previous
+ .catch( () => {} )
+ .then( () => syncOnce( clientId ) );
+ queues.set( clientId, next );
+ next.finally( () => {
+ if ( queues.get( clientId ) === next ) {
+ queues.delete( clientId );
+ }
+ } );
+ },
+ [ syncOnce ]
+ );
+
+ useEffect( () => {
+ if ( ! isSuggestMode ) {
+ return undefined;
+ }
+
+ const timers = timersRef.current;
+
+ for ( const [ clientId, entry ] of Object.entries( entries ) ) {
+ const operations = operationsFromOverlay(
+ entry.baselineAttributes,
+ entry.overlayAttributes
+ );
+ const fingerprint = fingerprintOperations( operations );
+ if ( fingerprint === entry.syncedOpsKey ) {
+ continue;
+ }
+
+ if ( timers.has( clientId ) ) {
+ clearTimeout( timers.get( clientId ) );
+ }
+ const timer = setTimeout( () => {
+ timers.delete( clientId );
+ enqueueSync( clientId );
+ }, AUTOSAVE_DEBOUNCE_MS );
+ timers.set( clientId, timer );
+ }
+
+ return undefined;
+ }, [ isSuggestMode, entries, enqueueSync ] );
+
+ // Clear all pending timers on unmount.
+ useEffect( () => {
+ const timers = timersRef.current;
+ return () => {
+ for ( const timer of timers.values() ) {
+ clearTimeout( timer );
+ }
+ timers.clear();
+ };
+ }, [] );
+
+ return null;
+}
diff --git a/packages/editor/src/components/suggestion-mode/commit-bar.js b/packages/editor/src/components/suggestion-mode/commit-bar.js
deleted file mode 100644
index feeaa4905ccafa..00000000000000
--- a/packages/editor/src/components/suggestion-mode/commit-bar.js
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-import {
- BlockControls,
- store as blockEditorStore,
-} from '@wordpress/block-editor';
-import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
-import { useSelect } from '@wordpress/data';
-import { useCallback, useState } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import { useSuggestionOverlay } from './overlay-context';
-import { operationsFromOverlay, useSuggestionsProvider } from './provider';
-import { EDITOR_STORE_NAME } from './constants';
-
-/**
- * Block toolbar group that surfaces Submit / Discard controls whenever the
- * currently selected block has a pending suggestion overlay.
- *
- * The bar is a shared singleton — mounted once per editor provider rather
- * than once per block — because the block-editor's `BlockControls` fill
- * automatically targets the selected block's toolbar slot.
- *
- * @return {React.ReactNode|null} Toolbar markup, or null if nothing pending.
- */
-export default function SuggestionCommitBar() {
- const { entries, clearOverlay } = useSuggestionOverlay();
- const { createSuggestion } = useSuggestionsProvider();
-
- const { selectedClientId, isSuggestMode } = useSelect( ( select ) => {
- return {
- selectedClientId:
- select( blockEditorStore ).getSelectedBlockClientId(),
- isSuggestMode:
- select( EDITOR_STORE_NAME ).getEditorIntent?.() === 'suggest',
- };
- }, [] );
-
- const [ isSubmitting, setIsSubmitting ] = useState( false );
- const entry = selectedClientId ? entries[ selectedClientId ] : null;
- const hasOverlay =
- !! entry && Object.keys( entry.overlayAttributes ).length > 0;
-
- const onSubmit = useCallback( async () => {
- if ( ! entry || isSubmitting ) {
- return;
- }
- const operations = operationsFromOverlay(
- entry.baselineAttributes,
- entry.overlayAttributes
- );
- if ( operations.length === 0 ) {
- clearOverlay( selectedClientId );
- return;
- }
- setIsSubmitting( true );
- try {
- await createSuggestion( {
- clientId: selectedClientId,
- blockName: entry.blockName,
- operations,
- } );
- clearOverlay( selectedClientId );
- } catch {
- // Notice surfaced by the provider.
- } finally {
- setIsSubmitting( false );
- }
- }, [
- entry,
- isSubmitting,
- selectedClientId,
- clearOverlay,
- createSuggestion,
- ] );
-
- const onDiscard = useCallback( () => {
- if ( selectedClientId ) {
- clearOverlay( selectedClientId );
- }
- }, [ selectedClientId, clearOverlay ] );
-
- if ( ! isSuggestMode || ! hasOverlay ) {
- return null;
- }
-
- return (
-
-
-
- { isSubmitting
- ? __( 'Submitting…' )
- : __( 'Submit suggestion' ) }
-
-
- { __( 'Discard' ) }
-
-
-
- );
-}
diff --git a/packages/editor/src/components/suggestion-mode/index.js b/packages/editor/src/components/suggestion-mode/index.js
index 52e389d570d6e4..19eaec10652496 100644
--- a/packages/editor/src/components/suggestion-mode/index.js
+++ b/packages/editor/src/components/suggestion-mode/index.js
@@ -7,7 +7,7 @@ export {
default as withSuggestionOverlay,
registerSuggestionOverlayFilter,
} from './with-suggestion-overlay';
-export { default as SuggestionCommitBar } from './commit-bar';
+export { default as SuggestionAutoSave } from './auto-save';
export { default as SuggestionStoreInterceptor } from './store-interceptor';
export {
useSuggestionsProvider,
diff --git a/packages/editor/src/components/suggestion-mode/overlay-context.js b/packages/editor/src/components/suggestion-mode/overlay-context.js
index ffc393a53d7282..43d560fa671236 100644
--- a/packages/editor/src/components/suggestion-mode/overlay-context.js
+++ b/packages/editor/src/components/suggestion-mode/overlay-context.js
@@ -81,6 +81,8 @@ const OverlayContext = createContext( {
captureBaseline: () => {},
setOverlayAttributes: () => {},
clearOverlay: () => {},
+ setCommentId: () => {},
+ setSyncedOpsKey: () => {},
hasOverlay: () => false,
requestInterceptorBypass: () => {},
consumeInterceptorBypass: () => false,
@@ -105,6 +107,8 @@ export function overlayReducer( state, action ) {
blockName: action.blockName,
baselineAttributes: action.attributes,
overlayAttributes: {},
+ commentId: null,
+ syncedOpsKey: null,
},
};
}
@@ -131,6 +135,32 @@ export function overlayReducer( state, action ) {
const { [ action.clientId ]: _removed, ...rest } = state;
return rest;
}
+ case 'SET_COMMENT_ID': {
+ const entry = state[ action.clientId ];
+ if ( ! entry ) {
+ return state;
+ }
+ return {
+ ...state,
+ [ action.clientId ]: {
+ ...entry,
+ commentId: action.commentId,
+ },
+ };
+ }
+ case 'SET_SYNCED_OPS_KEY': {
+ const entry = state[ action.clientId ];
+ if ( ! entry ) {
+ return state;
+ }
+ return {
+ ...state,
+ [ action.clientId ]: {
+ ...entry,
+ syncedOpsKey: action.syncedOpsKey,
+ },
+ };
+ }
case 'PRUNE_ORPHANS': {
// Action carries a serializable array; the reducer materializes a
// Set internally for the lookup. Keeps actions Redux-DevTools-
@@ -194,6 +224,18 @@ export function SuggestionOverlayProvider( { children } ) {
[]
);
+ const setCommentId = useCallback(
+ ( clientId, commentId ) =>
+ dispatch( { type: 'SET_COMMENT_ID', clientId, commentId } ),
+ []
+ );
+
+ const setSyncedOpsKey = useCallback(
+ ( clientId, syncedOpsKey ) =>
+ dispatch( { type: 'SET_SYNCED_OPS_KEY', clientId, syncedOpsKey } ),
+ []
+ );
+
const hasEntries = Object.keys( entries ).length > 0;
const hasOverlay = useCallback(
@@ -271,6 +313,8 @@ export function SuggestionOverlayProvider( { children } ) {
captureBaseline,
setOverlayAttributes,
clearOverlay,
+ setCommentId,
+ setSyncedOpsKey,
hasOverlay,
requestInterceptorBypass,
consumeInterceptorBypass,
@@ -280,6 +324,8 @@ export function SuggestionOverlayProvider( { children } ) {
captureBaseline,
setOverlayAttributes,
clearOverlay,
+ setCommentId,
+ setSyncedOpsKey,
hasOverlay,
requestInterceptorBypass,
consumeInterceptorBypass,
diff --git a/packages/editor/src/components/suggestion-mode/provider.js b/packages/editor/src/components/suggestion-mode/provider.js
index 4ea2cc50c8666b..6be6f6a8946cf0 100644
--- a/packages/editor/src/components/suggestion-mode/provider.js
+++ b/packages/editor/src/components/suggestion-mode/provider.js
@@ -362,6 +362,101 @@ export function useSuggestionsProvider() {
]
);
+ /**
+ * Update an existing suggestion's payload (auto-save path). Replaces
+ * the `_wp_suggestion` meta on the comment without changing its author,
+ * status, or thread identity, so the user sees a single note
+ * accumulating edits rather than a new note per save burst.
+ *
+ * @param {Object} args Update arguments.
+ * @param {number|string} args.commentId Comment id of the
+ * existing suggestion.
+ * @param {string} args.blockName Block name (recorded
+ * on the payload).
+ * @param {SuggestionOperation[]} args.operations Latest operations.
+ * @return {Promise