Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
9 changes: 8 additions & 1 deletion packages/core-data/src/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { getSyncManager } from './sync';
import { LOCAL_EDITOR_ORIGIN, getSyncManager } from './sync';
import {
applyPostChangesToCRDTDoc,
defaultApplyChangesToCRDTDoc,
Expand Down Expand Up @@ -280,6 +280,13 @@ export const prePersistPostType = (
...edits.meta,
...meta,
};

// Mark the doc as having been persisted.
getSyncManager()?.markPersisted(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has not yet been persisted at this point, so this creates a race condition if peers use it as a signal to refetch the entity

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want that ability in place? I took away the refetch the entity comment for that reason and made it seem instead its for notifications.

I had tried to put it under actions like it used to be, but that didn't consistently get triggered which was odd.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other option that I had in mind actually was the action - editor.savePost. That's fired right after the post update finishes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took away the refetch the entity comment for that reason

It served an important purpose in syncing the state of the Save / Publish button, so that peers can update their entity record in their local store and correctly compute "dirty" state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But then the question is, where would that be called? In the VIP-RTC plugin maybe? So once could decide if they truly want this feature or not and how they use it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be called in the sync manager. You can see how it previously worked here:

https://github.com/Automattic/gutenberg/blob/686d1b3381f29198d31af24b76c601abdc5d6da5/packages/sync/src/provider.ts#L124

Right now in our plugin branch, we've regressed to a non-synced Save / Publish button state. Refetching is how we solved that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies, I meant to say that do we want that ability in this place not at all.

I definitely know why we had that in place, as we went through several PRs to figure out the right solution for it. My thought was that, we'd use this state key way just for notifications so if it is a touch fragile it won't be a big deal. But, for getting the publish/save state synced we'd need to make sure that's perfect.

I have an alternative area that I'm experimenting with, which is the editor.savePost action.

Would perhaps re-naming these two keys to something else help? Or do you think it's better to just do it all in one way similar to how we did it before?

I'm approaching separately, so notifications and features like re-fetching are done separately.

objectType,
objectId,
LOCAL_EDITOR_ORIGIN
);
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/sync/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const CRDT_STATE_MAP_KEY = 'state';

// Y.Map keys for the state map.
export const CRDT_STATE_VERSION_KEY = 'version';
export const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt';
export const CRDT_STATE_PERSISTED_BY_KEY = 'persistedBy';

/**
* Origin string for CRDT document changes originating from the local editor.
Expand Down
40 changes: 40 additions & 0 deletions packages/sync/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import * as Y from 'yjs';
import {
CRDT_RECORD_MAP_KEY as RECORD_KEY,
LOCAL_SYNC_MANAGER_ORIGIN,
CRDT_STATE_PERSISTED_AT_KEY as PERSISTED_AT_KEY,
CRDT_STATE_PERSISTED_BY_KEY as PERSISTED_BY_KEY,
} from './config';
import { createPersistedCRDTDoc, getPersistedCrdtDoc } from './persistence';
import { getProviderCreators } from './providers';
Expand Down Expand Up @@ -305,11 +307,49 @@ export function createSyncManager(): SyncManager {
return createPersistedCRDTDoc( entityState.ydoc );
}

/**
* Update the last persisted timestamp, and the user who last persisted the document in the CRDT document state map.
*
* This can be used to send notifications to other peers about when the document
* was last updated, and by whom.
*
* @param {ObjectType} objectType Object type.
* @param {ObjectID} objectId Object ID.
* @param {string} origin The source of change.
*/
function recordPersistence(
objectType: ObjectType,
objectId: ObjectID,
origin: string
): void {
const entityId = getEntityId( objectType, objectId );
const entityState = entityStates.get( entityId );

if ( ! entityState ) {
return;
}

const { ydoc } = entityState;

const lastPersistedAt = Date.now();

Y.transact(
ydoc,
() => {
const stateMap = ydoc.getMap( 'state' );
stateMap.set( PERSISTED_AT_KEY, lastPersistedAt );
stateMap.set( PERSISTED_BY_KEY, ydoc.clientID );
Comment on lines +360 to +361
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been wondering if this is really the best approach. It's not really document state and it feels fragile.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I guess is that, we need to be able to tell the other clients that the document has been updated and by whom without spamming notifications.

If we hook into each change, we'd be spamming notifications. If we hook into when the post is saved, then those changes are batched at least.

That's why I stuck with this approach.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized why I like this approach. It's like our very own redux action that we can fire that plugins can hook into to react the way they want.

},
origin
);
}

return {
createMeta: createEntityMeta,
load: loadEntity,
undoManager,
unload: unloadEntity,
update: updateCRDTDoc,
markPersisted: recordPersistence,
};
}
138 changes: 138 additions & 0 deletions packages/sync/src/test/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import {
import { createSyncManager } from '../manager';
import {
CRDT_RECORD_MAP_KEY,
CRDT_STATE_MAP_KEY,
CRDT_STATE_PERSISTED_AT_KEY,
CRDT_STATE_PERSISTED_BY_KEY,
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
} from '../config';
import { createPersistedCRDTDoc } from '../persistence';
Expand Down Expand Up @@ -714,4 +717,139 @@ describe( 'SyncManager', () => {
expect( mockHandlers.editRecord ).not.toHaveBeenCalled();
} );
} );

describe( 'markPersisted', () => {
it( 'updates the state map with timestamp and client ID', async () => {
// Capture the Y.Doc from provider creator.
let capturedDoc: Y.Doc | null = null;
mockProviderCreator.mockImplementation(
async (
_objectType: string,
_objectId: string,
ydoc: Y.Doc
) => {
capturedDoc = ydoc;
return mockProviderResult;
}
);

const manager = createSyncManager();

await manager.load(
mockSyncConfig,
'post',
'123',
mockRecord,
mockHandlers
);

expect( capturedDoc ).not.toBeNull();
const ydoc = capturedDoc as unknown as Y.Doc;
const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY );

const beforeTime = Date.now();
manager.markPersisted( 'post', '123', 'test-origin' );
const afterTime = Date.now();

const persistedAt = stateMap.get(
CRDT_STATE_PERSISTED_AT_KEY
) as number;
const persistedBy = stateMap.get(
CRDT_STATE_PERSISTED_BY_KEY
) as number;

expect( persistedAt ).toBeGreaterThanOrEqual( beforeTime );
expect( persistedAt ).toBeLessThanOrEqual( afterTime );
expect( persistedBy ).toBe( ydoc.clientID );
} );

it( 'does not throw when entity is not loaded', () => {
const manager = createSyncManager();

expect( () => {
manager.markPersisted( 'post', '999', 'test-origin' );
} ).not.toThrow();
} );

it( 'applies changes with specified origin', async () => {
// Capture the Y.Doc from provider creator.
let capturedDoc: Y.Doc | null = null;
mockProviderCreator.mockImplementation(
async (
_objectType: string,
_objectId: string,
ydoc: Y.Doc
) => {
capturedDoc = ydoc;
return mockProviderResult;
}
);

const manager = createSyncManager();

await manager.load(
mockSyncConfig,
'post',
'123',
mockRecord,
mockHandlers
);

expect( capturedDoc ).not.toBeNull();
const ydoc = capturedDoc as unknown as Y.Doc;

let capturedOrigin: string | undefined;
ydoc.on( 'afterTransaction', ( transaction: Y.Transaction ) => {
capturedOrigin = transaction.origin as string | undefined;
} );

const customOrigin = 'custom-persist-origin';
manager.markPersisted( 'post', '123', customOrigin );

expect( capturedOrigin ).toBe( customOrigin );
} );

it( 'updates timestamp each time markPersisted is called', async () => {
// Capture the Y.Doc from provider creator.
let capturedDoc: Y.Doc | null = null;
mockProviderCreator.mockImplementation(
async (
_objectType: string,
_objectId: string,
ydoc: Y.Doc
) => {
capturedDoc = ydoc;
return mockProviderResult;
}
);

const manager = createSyncManager();

await manager.load(
mockSyncConfig,
'post',
'123',
mockRecord,
mockHandlers
);

expect( capturedDoc ).not.toBeNull();
const ydoc = capturedDoc as unknown as Y.Doc;
const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY );

manager.markPersisted( 'post', '123', 'test-origin' );
const firstTimestamp = stateMap.get(
CRDT_STATE_PERSISTED_AT_KEY
) as number;

await new Promise( ( resolve ) => setTimeout( resolve, 10 ) );

manager.markPersisted( 'post', '123', 'test-origin' );
const secondTimestamp = stateMap.get(
CRDT_STATE_PERSISTED_AT_KEY
) as number;

expect( secondTimestamp ).toBeGreaterThan( firstTimestamp );
} );
} );
} );
5 changes: 5 additions & 0 deletions packages/sync/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export interface SyncManager {
changes: Partial< ObjectData >,
origin: string
) => void;
markPersisted: (
objectType: ObjectType,
objectId: ObjectID,
origin: string
) => void;
}

export interface SyncUndoManager extends WPUndoManager< ObjectData > {
Expand Down
Loading