@@ -10,6 +10,7 @@ import * as Y from 'yjs';
1010import type {
1111 EngineCollection ,
1212 EngineEntity ,
13+ ObjectData ,
1314 SyncEngine ,
1415} from '@wordpress/sync' ;
1516
@@ -52,6 +53,9 @@ import {
5253 * - `getEditorChanges` reports nothing until bootstrap, so an empty
5354 * pre-sync document can never be dispatched into the editor as a
5455 * mass deletion.
56+ * - After bootstrap, the dirtying `content` edit is withheld while the
57+ * document still serializes byte-identical to the loaded record, so
58+ * merely opening a post does not mark the editor dirty.
5559 *
5660 * After bootstrap the editor's blocks originate from this document's own
5761 * JSON, so steady-state diffs (`mergeCrdtBlocks`) are no-ops for
@@ -78,6 +82,22 @@ export function createYjsServerEngine(): SyncEngine {
7882 const isBootstrapped = ( ) =>
7983 undefined !== stateMap . get ( VERSION_KEY ) ;
8084
85+ // Because hydrate() is a no-op, the server's genesis snapshot
86+ // arrives as a REMOTE change whose content typically matches the
87+ // loaded record byte-for-byte. The post sync config still reports
88+ // it: its `blocks` case cannot compare document blocks to editor
89+ // blocks (the two sides mint different block identities), so it
90+ // reports `blocks` on every remote change and injects a fresh
91+ // `content` serializer alongside. `blocks` is a transient edit,
92+ // but `content` is not, so merely opening a post marked the
93+ // editor dirty, activated the Save button, and scheduled
94+ // autosaves of unchanged content. Until the document and the
95+ // record first genuinely diverge, withhold that `content` edit
96+ // whenever the reported blocks serialize byte-identical to the
97+ // record's raw content. The `blocks` edit still dispatches so the
98+ // editor adopts the document's block identities at bootstrap.
99+ let docMayStillMatchRecord = true ;
100+
81101 // Edits made before the server snapshot arrives, replayed in
82102 // order once it does.
83103 let pendingLocalChanges : Array < {
@@ -159,10 +179,37 @@ export function createYjsServerEngine(): SyncEngine {
159179 applyChanges ( changes , origin , Boolean ( options . isSave ) ) ;
160180 } ,
161181
162- getEditorChanges : ( editedRecord ) =>
163- isBootstrapped ( )
164- ? syncConfig . getChangesFromCRDTDoc ( ydoc , editedRecord )
165- : { } ,
182+ getEditorChanges : ( editedRecord ) => {
183+ if ( ! isBootstrapped ( ) ) {
184+ return { } ;
185+ }
186+
187+ const changes = syncConfig . getChangesFromCRDTDoc (
188+ ydoc ,
189+ editedRecord
190+ ) ;
191+
192+ if ( ! docMayStillMatchRecord ) {
193+ return changes ;
194+ }
195+
196+ // An empty change set neither confirms nor refutes a
197+ // match; leave the guard armed for the next dispatch.
198+ if ( 0 === Object . keys ( changes ) . length ) {
199+ return changes ;
200+ }
201+
202+ if (
203+ isRedundantBootstrapDispatch ( changes , editedRecord )
204+ ) {
205+ const nonDirtyingChanges = { ...changes } ;
206+ delete nonDirtyingChanges . content ;
207+ return nonDirtyingChanges ;
208+ }
209+
210+ docMayStillMatchRecord = false ;
211+ return changes ;
212+ } ,
166213
167214 encodeSnapshot : ( ) => encodeDocSnapshot ( ydoc ) ,
168215
@@ -284,3 +331,83 @@ export function createYjsServerEngine(): SyncEngine {
284331 } ,
285332 } ;
286333}
334+
335+ /**
336+ * Change-set keys that may appear in a redundant bootstrap dispatch. `blocks`
337+ * and `selection` are transient (non-dirtying) entity edits; `content` is the
338+ * injected serializer the bootstrap guard withholds. Any other key means the
339+ * document genuinely diverges from the record.
340+ */
341+ const REDUNDANT_DISPATCH_KEYS = new Set ( [ 'blocks' , 'content' , 'selection' ] ) ;
342+
343+ /**
344+ * Extract the raw content string from an edited record's `content` property,
345+ * which is represented either as a plain string or as an object with a `raw`
346+ * property. Returns undefined for any other shape, notably the lazy serializer
347+ * function that replaces it once the editor has registered its own content
348+ * edit.
349+ *
350+ * @param value The edited record's `content` property.
351+ */
352+ function getRawContentString ( value : unknown ) : string | undefined {
353+ if ( 'string' === typeof value ) {
354+ return value ;
355+ }
356+
357+ if (
358+ value &&
359+ 'object' === typeof value &&
360+ 'raw' in value &&
361+ 'string' === typeof value . raw
362+ ) {
363+ return value . raw ;
364+ }
365+
366+ return undefined ;
367+ }
368+
369+ /**
370+ * Determine whether a reported change set merely re-states what the editor
371+ * already shows: the document's blocks serialize byte-identical to the
372+ * record's raw content, and nothing besides blocks, the injected content
373+ * serializer, and selection is reported. Such a dispatch carries no
374+ * information the editor lacks except the document's block identities, which
375+ * ride on the transient `blocks` edit alone.
376+ *
377+ * @param changes Changes reported by the sync config.
378+ * @param editedRecord The edited record the changes were computed against.
379+ */
380+ function isRedundantBootstrapDispatch (
381+ changes : ObjectData ,
382+ editedRecord : ObjectData
383+ ) : boolean {
384+ const contentEdit = changes . content ;
385+ const recordContent = getRawContentString ( editedRecord . content ) ;
386+
387+ if (
388+ ! changes . blocks ||
389+ 'function' !== typeof contentEdit ||
390+ 'string' !== typeof recordContent
391+ ) {
392+ return false ;
393+ }
394+
395+ const hasOnlyRedundantKeys = Object . keys ( changes ) . every ( ( key ) =>
396+ REDUNDANT_DISPATCH_KEYS . has ( key )
397+ ) ;
398+
399+ if ( ! hasOnlyRedundantKeys ) {
400+ return false ;
401+ }
402+
403+ // The injected serializer captures the reported blocks; invoking it here
404+ // trades one serialization for the comparison the sync config cannot make
405+ // itself (the document and the editor mint different block identities).
406+ // The trim mirrors the sync config's own persisted-document comparison.
407+ const serializedDocContent = contentEdit ( ) ;
408+
409+ return (
410+ 'string' === typeof serializedDocContent &&
411+ serializedDocContent . trim ( ) === recordContent
412+ ) ;
413+ }
0 commit comments