Skip to content

Commit 6d48222

Browse files
authored
Fix post dirty-on-load in yjs-server (#18)
1 parent 09e513d commit 6d48222

3 files changed

Lines changed: 337 additions & 4 deletions

File tree

src/engines/yjs-server/engine.ts

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as Y from 'yjs';
1010
import 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+
}

tests/e2e/specs/collaboration-yjs-server-engine.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,67 @@ test.describe( 'Collaboration - yjs-server engine', () => {
329329
] );
330330
} );
331331

332+
test( 'merely opening a post does not mark it dirty', async ( {
333+
collaborationUtils,
334+
requestUtils,
335+
page,
336+
} ) => {
337+
// Regression test for the bootstrap dirty state: the server's genesis
338+
// snapshot arrives as a remote change, and the dispatch that swapped
339+
// in the document's blocks used to register a dirtying `content` edit
340+
// even though nothing changed, activating the Save button and the
341+
// autosave timer on a post nobody touched.
342+
const post = await requestUtils.createPost( {
343+
title: 'Yjs Server Dirty On Open Test',
344+
status: 'draft',
345+
content:
346+
'<!-- wp:paragraph -->\n<p>Untouched content</p>\n<!-- /wp:paragraph -->',
347+
date_gmt: new Date().toISOString(),
348+
} );
349+
350+
await collaborationUtils.openPost( post.id );
351+
352+
const getEditKeys = () =>
353+
page.evaluate( ( postId ) => {
354+
const edits = window.wp.data
355+
.select( 'core' )
356+
.getEntityRecordEdits( 'postType', 'post', postId );
357+
return Object.keys( edits ?? {} );
358+
}, post.id );
359+
360+
// Wait for the server's genesis snapshot to bootstrap the session:
361+
// its dispatch registers the (transient, non-dirtying) `blocks` edit
362+
// that swaps in the document's block identities.
363+
await expect
364+
.poll( getEditKeys, { timeout: 20000 } )
365+
.toContain( 'blocks' );
366+
367+
// The bootstrap dispatch is exactly the moment the regression fired.
368+
// Keep watching across further poll cycles to catch a delayed flip.
369+
const becameDirty = await page.evaluate(
370+
() =>
371+
new Promise( ( resolve ) => {
372+
const started = Date.now();
373+
const interval = setInterval( () => {
374+
const isDirty = window.wp.data
375+
.select( 'core/editor' )
376+
.isEditedPostDirty();
377+
if ( isDirty ) {
378+
clearInterval( interval );
379+
resolve( true );
380+
} else if ( Date.now() - started > 8000 ) {
381+
clearInterval( interval );
382+
resolve( false );
383+
}
384+
}, 200 );
385+
} )
386+
);
387+
expect( becameDirty ).toBe( false );
388+
389+
// The dirtying half of the old dispatch must be gone for good.
390+
expect( await getEditKeys() ).not.toContain( 'content' );
391+
} );
392+
332393
test( 'title edits sync between users in both directions', async ( {
333394
collaborationUtils,
334395
requestUtils,

tests/js/engines/yjs-server/engine.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,149 @@ describe( 'createYjsServerEngine › createEntity', () => {
142142

143143
expect( onRemoteChange ).toHaveBeenCalled();
144144
} );
145+
146+
describe( 'bootstrap dirty guard', () => {
147+
const BLOCK_MARKUP =
148+
'<!-- wp:paragraph -->\n<p>Hello</p>\n<!-- /wp:paragraph -->';
149+
150+
function makeBootstrappedEntity() {
151+
const entity = makeEntity();
152+
entity.hydrate( {} as any, jest.fn() );
153+
entity.createSession().receiveUpdate( genesisRow() );
154+
return entity;
155+
}
156+
157+
/**
158+
* The change shape the framework's post sync config reports for a
159+
* live-document remote change: doc blocks plus an injected lazy
160+
* content serializer capturing them.
161+
*
162+
* @param serialized The string the injected serializer returns.
163+
*/
164+
function postShapedChanges( serialized: string ) {
165+
return {
166+
blocks: [ { name: 'core/paragraph' } ],
167+
content: () => serialized,
168+
};
169+
}
170+
171+
it( 'withholds the injected content edit when doc blocks serialize identically to the record content', () => {
172+
const entity = makeBootstrappedEntity();
173+
syncConfig.getChangesFromCRDTDoc.mockReturnValue(
174+
postShapedChanges( BLOCK_MARKUP )
175+
);
176+
177+
const changes = entity.getEditorChanges( {
178+
content: { raw: BLOCK_MARKUP },
179+
} as any );
180+
181+
// The blocks still dispatch (transient; the editor adopts the
182+
// document's block identities), but the dirtying content edit
183+
// does not.
184+
expect( changes.blocks ).toBeDefined();
185+
expect( changes ).not.toHaveProperty( 'content' );
186+
} );
187+
188+
it( 'accepts plain-string record content and tolerates trailing whitespace in the serialization', () => {
189+
const entity = makeBootstrappedEntity();
190+
syncConfig.getChangesFromCRDTDoc.mockReturnValue(
191+
postShapedChanges( `${ BLOCK_MARKUP }\n` )
192+
);
193+
194+
const changes = entity.getEditorChanges( {
195+
content: BLOCK_MARKUP,
196+
} as any );
197+
198+
expect( changes ).not.toHaveProperty( 'content' );
199+
} );
200+
201+
it( 'keeps withholding across repeated redundant dispatches and empty change sets', () => {
202+
const entity = makeBootstrappedEntity();
203+
const editedRecord = { content: { raw: BLOCK_MARKUP } } as any;
204+
205+
syncConfig.getChangesFromCRDTDoc.mockReturnValue(
206+
postShapedChanges( BLOCK_MARKUP )
207+
);
208+
expect(
209+
entity.getEditorChanges( editedRecord )
210+
).not.toHaveProperty( 'content' );
211+
212+
// An empty change set must not disarm the guard.
213+
syncConfig.getChangesFromCRDTDoc.mockReturnValue( {} );
214+
expect( entity.getEditorChanges( editedRecord ) ).toEqual( {} );
215+
216+
syncConfig.getChangesFromCRDTDoc.mockReturnValue(
217+
postShapedChanges( BLOCK_MARKUP )
218+
);
219+
expect(
220+
entity.getEditorChanges( editedRecord )
221+
).not.toHaveProperty( 'content' );
222+
} );
223+
224+
it( 'passes genuine divergence through and disarms the guard permanently', () => {
225+
const entity = makeBootstrappedEntity();
226+
const editedRecord = { content: { raw: BLOCK_MARKUP } } as any;
227+
228+
// A remote edit produced content the record does not have.
229+
syncConfig.getChangesFromCRDTDoc.mockReturnValue(
230+
postShapedChanges( `${ BLOCK_MARKUP }\n<!-- wp:more -->` )
231+
);
232+
const diverged = entity.getEditorChanges( editedRecord );
233+
expect( typeof diverged.content ).toBe( 'function' );
234+
235+
// Even a later identical-looking dispatch is no longer filtered:
236+
// steady-state behavior is restored for the rest of the session.
237+
syncConfig.getChangesFromCRDTDoc.mockReturnValue(
238+
postShapedChanges( BLOCK_MARKUP )
239+
);
240+
const settled = entity.getEditorChanges( editedRecord );
241+
expect( typeof settled.content ).toBe( 'function' );
242+
} );
243+
244+
it( 'does not withhold when the edited record already carries its own content edit', () => {
245+
const entity = makeBootstrappedEntity();
246+
syncConfig.getChangesFromCRDTDoc.mockReturnValue(
247+
postShapedChanges( BLOCK_MARKUP )
248+
);
249+
250+
// Once the user edits, the record's content is a lazy serializer
251+
// function, not a raw string; the guard must stand aside.
252+
const changes = entity.getEditorChanges( {
253+
content: () => BLOCK_MARKUP,
254+
} as any );
255+
256+
expect( typeof changes.content ).toBe( 'function' );
257+
} );
258+
259+
it( 'does not withhold when other properties changed alongside blocks', () => {
260+
const entity = makeBootstrappedEntity();
261+
syncConfig.getChangesFromCRDTDoc.mockReturnValue( {
262+
...postShapedChanges( BLOCK_MARKUP ),
263+
title: 'Remote title',
264+
} );
265+
266+
const changes = entity.getEditorChanges( {
267+
content: { raw: BLOCK_MARKUP },
268+
} as any );
269+
270+
expect( typeof changes.content ).toBe( 'function' );
271+
expect( changes.title ).toBe( 'Remote title' );
272+
} );
273+
274+
it( 'preserves a shifted selection when withholding the content edit', () => {
275+
const entity = makeBootstrappedEntity();
276+
const selection = { selectionStart: {}, selectionEnd: {} };
277+
syncConfig.getChangesFromCRDTDoc.mockReturnValue( {
278+
...postShapedChanges( BLOCK_MARKUP ),
279+
selection,
280+
} );
281+
282+
const changes = entity.getEditorChanges( {
283+
content: { raw: BLOCK_MARKUP },
284+
} as any );
285+
286+
expect( changes.selection ).toBe( selection );
287+
expect( changes ).not.toHaveProperty( 'content' );
288+
} );
289+
} );
145290
} );

0 commit comments

Comments
 (0)