Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/explanations/architecture/suggestions.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,13 @@ REST/PHP surface lives in `lib/compat/wordpress-6.9/`:
| `block-comments.php` | Registers the `_wp_note_status`, `_wp_suggestion`, and `_wp_suggestion_status` comment meta and adds `editor.notes` post-type support. |
| `class-gutenberg-rest-comment-controller-6-9.php` | REST controller subclass remapping permissions for `note`-type comments (post editors get `edit_post`-based access; updates are gated by an allowlist of suggestion-lifecycle fields). |

## Suggestion Payload (v1)
## Suggestion Payload (v2)

Stored as a JSON string in the `_wp_suggestion` comment meta on a `note` comment:

```json
{
"schemaVersion": 1,
"schemaVersion": 2,
"blockName": "core/paragraph",
"baseRevision": "2026-04-15T12:34:56",
"operations": [
Expand All @@ -133,10 +133,14 @@ Stored as a JSON string in the `_wp_suggestion` comment meta on a `note` comment
| `schemaVersion` | Allows future schema evolution without breaking old payloads. |
| `blockName` | Safety check — apply is refused if the block type has changed. |
| `baseRevision` | `post_modified_gmt` at capture time. A mismatch at apply time triggers a staleness warning. |
| `operations` | Declarative transforms on the block tree. Currently only `attribute-set`; designed to extend to `block-insert-after`, `block-remove` in the future. |
| `operations` | Declarative transforms on the block tree. v1 emitted `attribute-set` only; v2 reserves the structural variants (`block-insert-after`, `block-remove`, `block-move`) tracked in [#77434](https://github.com/WordPress/gutenberg/issues/77434). |

Operations are **declarative transforms**, not HTML diffs. This makes them compatible with Yjs attribution semantics and resilient to concurrent edits on unrelated attributes.

### v1 → v2 compatibility

The shape of a v1 payload is a strict subset of v2 (only `attribute-set` operations). v1 payloads are migrated forward in `parseSuggestionPayload` by stamping `schemaVersion: 2` — no rewriting needed. The bump matters because a v1 reader that encountered a v2 payload with structural ops would silently drop them at apply time; refusing the payload outright surfaces an explicit "newer editor" notice and offers only Reject.

### Schema versioning

`schemaVersion` is incremented whenever the payload shape changes. Consumers apply the following rule:
Expand Down
87 changes: 70 additions & 17 deletions packages/editor/src/components/suggestion-mode/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import {

/**
* @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.
* @property {'attribute-set'|'block-insert-after'|'block-remove'|'block-move'} type
* Operation type. `attribute-set` ships in Phase 2; the structural
* variants ship in Phase 6 (issue #77434).
* @property {string} [attribute] The attribute being changed
* (`attribute-set` only).
* @property {*} [before] The baseline value (`attribute-set`).
* @property {*} [after] The proposed value (`attribute-set`).
*/

/**
Expand All @@ -37,7 +39,20 @@ import {
* @property {SuggestionOperation[]} operations Ordered operations.
*/

const SCHEMA_VERSION = 1;
/**
* Suggestion payload schema version. v1 emitted only `attribute-set`
* operations; v2 reserves the structural op types (`block-insert-after`,
* `block-remove`, `block-move`) tracked in issue #77434.
*
* Reader rule:
* parsed < SCHEMA_VERSION → migrate forward, then apply.
* parsed === SCHEMA_VERSION → apply as-is.
* parsed > SCHEMA_VERSION → refuse (newer-editor notice; offer Reject only).
*
* Bumping this constant requires a corresponding migration step in
* `parseSuggestionPayload`.
*/
const SCHEMA_VERSION = 2;

/**
* Maximum byte length of a serialized suggestion payload. Mirrors
Expand Down Expand Up @@ -216,28 +231,66 @@ export function hasAttributeConflict( currentAttributes, operations ) {
}

/**
* Parse a `_wp_suggestion` meta value into a typed payload.
* Migrate a payload emitted by an older `SCHEMA_VERSION` up to the current
* shape. v1 → v2 is a pure additive change (structural op types reserved but
* v1 payloads never used them), so the migration just stamps the version
* field forward — no shape rewriting is needed.
*
* Add a new `case` per future bump; never remove old cases, since the
* comment-meta store may contain payloads written by every prior version.
*
* @param {Object} parsed Parsed JSON payload of a known older version.
* @return {Object} Payload upgraded to the current schema.
*/
function migrateSuggestionPayload( parsed ) {
let next = parsed;
if ( next.schemaVersion === 1 ) {
next = { ...next, schemaVersion: 2 };
}
return next;
}

/**
* Parse a `_wp_suggestion` meta value into a typed payload. Refuses payloads
* written by a newer editor (`schemaVersion > SCHEMA_VERSION`) so a partial
* apply can't drop op types this consumer doesn't understand. Migrates
* older payloads forward to the current shape.
*
* @param {string|undefined} raw The raw JSON string from comment meta.
* @return {SuggestionPayload|null} Parsed payload, or null if invalid.
* @return {SuggestionPayload|null} Parsed payload, or null when the input is
* malformed or the payload was written by a newer editor.
*/
export function parseSuggestionPayload( raw ) {
if ( ! raw ) {
return null;
}
let parsed;
try {
const parsed = JSON.parse( raw );
if (
typeof parsed === 'object' &&
parsed !== null &&
Array.isArray( parsed.operations )
) {
return parsed;
}
return null;
parsed = JSON.parse( raw );
} catch {
return null;
}
if (
typeof parsed !== 'object' ||
parsed === null ||
! Array.isArray( parsed.operations )
) {
return null;
}
// Pre-versioned payloads (schemaVersion missing) are treated as v1 — the
// only writer that emitted them was the v1 implementation.
const version =
typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : 1;
if ( version > SCHEMA_VERSION ) {
return null;
}
if ( version < SCHEMA_VERSION ) {
return migrateSuggestionPayload( {
...parsed,
schemaVersion: version,
} );
}
return parsed;
}

/**
Expand Down
58 changes: 56 additions & 2 deletions packages/editor/src/components/suggestion-mode/test/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,9 @@ describe( 'hasAttributeConflict', () => {
} );

describe( 'parseSuggestionPayload', () => {
it( 'parses a valid JSON payload', () => {
it( 'parses a valid current-version JSON payload', () => {
const raw = JSON.stringify( {
schemaVersion: 1,
schemaVersion: 2,
blockName: 'core/paragraph',
baseRevision: '2026-04-15T00:00:00',
operations: [
Expand All @@ -265,10 +265,64 @@ describe( 'parseSuggestionPayload', () => {
} );
const result = parseSuggestionPayload( raw );
expect( result ).not.toBeNull();
expect( result.schemaVersion ).toBe( 2 );
expect( result.operations ).toHaveLength( 1 );
expect( result.blockName ).toBe( 'core/paragraph' );
} );

it( 'migrates a v1 payload forward to the current version', () => {
const raw = JSON.stringify( {
schemaVersion: 1,
blockName: 'core/paragraph',
baseRevision: null,
operations: [
{
type: 'attribute-set',
attribute: 'content',
before: 'a',
after: 'b',
},
],
} );
const result = parseSuggestionPayload( raw );
expect( result ).not.toBeNull();
expect( result.schemaVersion ).toBe( 2 );
expect( result.operations ).toHaveLength( 1 );
} );

it( 'treats a missing schemaVersion as v1 and migrates it', () => {
const raw = JSON.stringify( {
blockName: 'core/paragraph',
baseRevision: null,
operations: [
{
type: 'attribute-set',
attribute: 'content',
before: 'a',
after: 'b',
},
],
} );
const result = parseSuggestionPayload( raw );
expect( result ).not.toBeNull();
expect( result.schemaVersion ).toBe( 2 );
} );

it( 'refuses a payload from a newer editor', () => {
const raw = JSON.stringify( {
schemaVersion: 99,
blockName: 'core/paragraph',
baseRevision: null,
operations: [
{
type: 'block-rotate',
clientId: 'x',
},
],
} );
expect( parseSuggestionPayload( raw ) ).toBeNull();
} );

it( 'returns null for missing, empty, or invalid input', () => {
expect( parseSuggestionPayload( undefined ) ).toBeNull();
expect( parseSuggestionPayload( '' ) ).toBeNull();
Expand Down
Loading