Skip to content
Merged
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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions packages/core-data/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { receiveItems, removeItems, receiveQueriedItems } from './queried-data';
import { DEFAULT_ENTITY_KEY } from './entities';
import { createBatch } from './batch';
import { STORE_NAME } from './name';
import { getSyncProvider } from './sync';
import { LOCAL_EDITOR_ORIGIN, syncManager } from './sync';
import logEntityDeprecation from './utils/log-entity-deprecation';

/**
Expand Down Expand Up @@ -413,7 +413,12 @@ export const editEntityRecord =
const objectType = `${ kind }/${ name }`;
const objectId = recordId;

getSyncProvider().update( objectType, objectId, edit.edits );
syncManager.update(
objectType,
objectId,
edit.edits,
LOCAL_EDITOR_ORIGIN
);
}
}
if ( ! options.undoIgnore ) {
Expand Down
43 changes: 23 additions & 20 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import apiFetch from '@wordpress/api-fetch';
*/
import { STORE_NAME } from './name';
import { additionalEntityConfigLoaders, DEFAULT_ENTITY_KEY } from './entities';
import { syncManager } from './sync';
import {
forwardResolver,
getNormalizedCommaSeparable,
Expand All @@ -23,7 +24,6 @@ import {
ALLOWED_RESOURCE_ACTIONS,
RECEIVE_INTERMEDIATE_RESULTS,
} from './utils';
import { getSyncProvider } from './sync';
import { fetchBlockPatterns } from './fetch';

/**
Expand Down Expand Up @@ -165,7 +165,7 @@ export const getEntityRecord =
const objectId = key;

// Use the new transient "read/write" config to compute transients for
// the sync provider. Otherwise these transients are not available
// the sync manager. Otherwise these transients are not available
// if / until the record is edited. Use a copy of the record so that
// it does not change the behavior outside this experimental flag.
const recordWithTransients = { ...record };
Expand All @@ -184,27 +184,30 @@ export const getEntityRecord =
transientConfig.read( recordWithTransients );
} );

getSyncProvider().register(
objectType,
entityConfig.syncConfig
);

// Bootstraps the edited document (and load from peers).
await getSyncProvider().bootstrap(
// Load the entity record for syncing.
await syncManager.load(
entityConfig.syncConfig,
objectType,
objectId,
recordWithTransients,
( edits ) => {
dispatch( {
type: 'EDIT_ENTITY_RECORD',
kind,
name,
recordId: key,
edits,
meta: {
undo: undefined,
},
} );
{
// Handle edits sourced from the sync manager.
editRecord: ( edits ) => {
if ( ! Object.keys( edits ).length ) {
return;
}

dispatch( {
type: 'EDIT_ENTITY_RECORD',
kind,
name,
recordId: key,
edits,
meta: {
undo: undefined,
},
} );
},
}
);
}
Expand Down
27 changes: 0 additions & 27 deletions packages/core-data/src/sync.js

This file was deleted.

11 changes: 11 additions & 0 deletions packages/core-data/src/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* WordPress dependencies
*/
import {
CRDT_RECORD_MAP_KEY,
LOCAL_EDITOR_ORIGIN,
createSyncManager,
} from '@wordpress/sync';

export { CRDT_RECORD_MAP_KEY, LOCAL_EDITOR_ORIGIN };
export const syncManager = createSyncManager();
56 changes: 24 additions & 32 deletions packages/core-data/src/test/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
*/
import triggerFetch from '@wordpress/api-fetch';

jest.mock( '@wordpress/api-fetch' );
/**
* Internal dependencies
*/
import { syncManager } from '../sync';

// Mock the sync provider
jest.mock( '@wordpress/api-fetch' );
jest.mock( '../sync', () => ( {
getSyncProvider: jest.fn(),
syncManager: {
load: jest.fn(),
},
} ) );

/**
Expand All @@ -21,7 +26,6 @@ import {
getAutosaves,
getCurrentUser,
} from '../resolvers';
import { getSyncProvider } from '../sync';

describe( 'getEntityRecord', () => {
const POST_TYPE = { slug: 'post' };
Expand All @@ -47,7 +51,7 @@ describe( 'getEntityRecord', () => {
finishResolutions: jest.fn(),
} );
triggerFetch.mockReset();
getSyncProvider.mockClear();
syncManager.load.mockClear();
} );

afterEach( () => {
Expand Down Expand Up @@ -123,7 +127,7 @@ describe( 'getEntityRecord', () => {
);
} );

it( 'bootstraps entity with sync provider when __experimentalEnableSync is true', async () => {
it( 'loads entity with sync manager when __experimentalEnableSync is true', async () => {
const POST_RECORD = { id: 1, title: 'Test Post' };
const POST_RESPONSE = {
json: () => Promise.resolve( POST_RECORD ),
Expand All @@ -140,12 +144,6 @@ describe( 'getEntityRecord', () => {

window.__experimentalEnableSync = true;

const mockBootstrap = jest.fn();
getSyncProvider.mockReturnValue( {
bootstrap: mockBootstrap,
register: jest.fn(),
} );

const resolveSelectWithSync = {
getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
getEditedEntityRecord: jest.fn(),
Expand All @@ -163,14 +161,14 @@ describe( 'getEntityRecord', () => {
resolveSelect: resolveSelectWithSync,
} );

// Verify bootstrap was called with correct arguments.
expect( getSyncProvider ).toHaveBeenCalled();
expect( mockBootstrap ).toHaveBeenCalledTimes( 1 );
expect( mockBootstrap ).toHaveBeenCalledWith(
// Verify load was called with correct arguments.
expect( syncManager.load ).toHaveBeenCalledTimes( 1 );
expect( syncManager.load ).toHaveBeenCalledWith(
{},
'postType/post',
1,
POST_RECORD,
expect.any( Function )
{ editRecord: expect.any( Function ) }
);
} );

Expand All @@ -196,12 +194,6 @@ describe( 'getEntityRecord', () => {

window.__experimentalEnableSync = true;

const mockBootstrap = jest.fn();
getSyncProvider.mockReturnValue( {
bootstrap: mockBootstrap,
register: jest.fn(),
} );

const resolveSelectWithSync = {
getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
getEditedEntityRecord: jest.fn(),
Expand All @@ -219,18 +211,18 @@ describe( 'getEntityRecord', () => {
resolveSelect: resolveSelectWithSync,
} );

// Verify bootstrap was called with correct arguments.
expect( getSyncProvider ).toHaveBeenCalled();
expect( mockBootstrap ).toHaveBeenCalledTimes( 1 );
expect( mockBootstrap ).toHaveBeenCalledWith(
// Verify load was called with correct arguments.
expect( syncManager.load ).toHaveBeenCalledTimes( 1 );
expect( syncManager.load ).toHaveBeenCalledWith(
{},
'postType/post',
1,
{ ...POST_RECORD, foo: 'bar' },
expect.any( Function )
{ editRecord: expect.any( Function ) }
);
} );

it( 'does not bootstrap entity when query is present', async () => {
it( 'does not load entity when query is present', async () => {
const POST_RECORD = { id: 1, title: 'Test Post' };
const POST_RESPONSE = {
json: () => Promise.resolve( POST_RECORD ),
Expand Down Expand Up @@ -260,10 +252,10 @@ describe( 'getEntityRecord', () => {
resolveSelect: resolveSelectWithSync,
} );

expect( getSyncProvider ).not.toHaveBeenCalled();
expect( syncManager.load ).not.toHaveBeenCalled();
} );

it( 'does not bootstrap entity when __experimentalEnableSync is undefined', async () => {
it( 'does not load entity when __experimentalEnableSync is undefined', async () => {
const POST_RECORD = { id: 1, title: 'Test Post' };
const POST_RESPONSE = {
json: () => Promise.resolve( POST_RECORD ),
Expand Down Expand Up @@ -294,7 +286,7 @@ describe( 'getEntityRecord', () => {
resolveSelect: resolveSelectWithSync,
} );

expect( getSyncProvider ).not.toHaveBeenCalled();
expect( syncManager.load ).not.toHaveBeenCalled();
} );
} );

Expand Down
11 changes: 6 additions & 5 deletions packages/core-data/src/utils/crdt.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/**
* WordPress dependencies
*/
import {
type CRDTDoc,
CRDT_RECORD_MAP_KEY,
type ObjectData,
} from '@wordpress/sync';
import { type CRDTDoc, type ObjectData } from '@wordpress/sync';

/**
* Internal dependencies
*/
import { CRDT_RECORD_MAP_KEY } from '../sync';

export function defaultApplyChangesToCRDTDoc(
crdtDoc: CRDTDoc,
Expand Down
1 change: 1 addition & 0 deletions packages/private-apis/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [
'@wordpress/preferences',
'@wordpress/reusable-blocks',
'@wordpress/router',
'@wordpress/sync',
'@wordpress/dataviews',
'@wordpress/fields',
'@wordpress/media-utils',
Expand Down
72 changes: 32 additions & 40 deletions packages/sync/CODE.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,46 @@
# Status of the sync experiment in Gutenberg

The sync package is part of an ongoing research effort to lay the groundwork of Real-Time Collaboration in Gutenberg.
The sync package provides an implementation of real-time collaboration in Gutenberg.

Relevant docs:
Relevant docs and discussions:

- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/
- https://github.com/WordPress/gutenberg/issues/52593
- https://docs.yjs.dev/
- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/
- https://github.com/WordPress/gutenberg/issues/52593
- https://github.com/WordPress/gutenberg/discussions/65012
- https://docs.yjs.dev/

## Enable the experiment

The experiment can be enabled in the "Guteberg > Experiments" page. When it is enabled (search for `gutenberg-sync-collaboration` in the codebase), the client receives two new pieces of data:
The real-time collaboration experiment must be enabled on the "Gutenberg > Experiments" page. A WebRTC provider with HTTP signaling is used to connect peers.

- `window.__experimentalEnableSync`: boolean. Used by the `core-data` package to determine whether to bootstrap and use the sync provider offered by the `sync` package.
- `window.__experimentalCollaborativeEditingSecret`: string. A secret used by the `sync` package to create a secure connection among peers.
When it is enabled, the following global variables are defined::

- `window.__experimentalEnableSync` (`boolean`): Used by the `core-data` package to determine whether entity syncing is available.
- `window.__experimentalCollaborativeEditingSecret` (`string`). A secret (stored in a WordPress option) used by the WebRTC provider to create a secure connection between peers.

## The data flow

The current experiment updates `core-data` to leverage the YJS library for synchronization and merging changes. Each core-data entity record represents a YJS document and updates to the `--edit` record are broadcasted among peers.
Each entity with sync enabled is represented by a CRDT (Yjs) document. Local edits (unsaved changes) to an entity record are applied to its CRDT document, which is synced with other peers via a provider. Those peers use the CRDT document to update their local state.

These are the specific checkpoints:

1. REGISTER.
- See `getSyncProvider().register( ... )` in `registerSyncConfigs`.
- Not all entity types are sync-enabled at the moment, look at those that declare a `syncConfig` and `syncObjectType` in `rootEntitiesConfig`.
2. BOOTSTRAP.
- See `getSyncProvider().bootstrap( ... )` in `getEntityRecord`.
- The `bootstrap` function fetches the entity and sets up the callback that will dispatch the relevant Redux action when document changes are broadcasted from other peers.
3. UPDATE.
- See `getSyncProvider().update( ... )` in `editEntityRecord`.
- Each change done by a peer to the `--edit` entity record (local changes, not persisted ones) is broadcasted to the others.
- The data that is shared is the whole block list.

This is the data flow when the peer A makes a local change:

- Peer A makes a local change.
- Peer A triggers a `getSyncProvider().update( ... )` request (see `editEntityRecord`).
- All peers (including A) receive the broadcasted change and execute the callback (see `updateHandler` in `createSyncProvider.bootstrap`).
- All peers (including A) trigger a `EDIT_ENTITY_RECORD` redux action.

## What works and what doesn't

- Undo/redo does not work.
- Changes can be persisted and the publish/update button should react accordingly for all peers.
- Offline.
- Changes are stored in the browser's local storage (indexedDB) for each user/peer. Users can navigate away from the document and they'll see the changes when they come back.
- Offline changes can be deleted via visiting the browser's database in all peers, then reload the document.
- Documents can get out of sync. For example:
- Two peers open the same document.
- One of them (A) leaves the document. Then, the remaining user (B) makes changes.
- When A comes back to the document, the changes B made are not visible to A.
- Entities
- Not all entities are synced. For example, global styles are not. Look at the `base` entity config for an example (it declares `syncConfig` and `syncObjectType` properties).
1. **CONFIG**: The entity's config defines a `syncConfig` property to enable syncing for that entity type and define its behavior.
- See `packages/core-data/src/entities.js`.
- Not all entities are sync-enabled; look for those that define a `syncConfig` property.
- Not all properties are synced; look for the `syncedProperties` set that is passed as an argument to various functions.
2. **LOAD**: When an entity record is loaded for the first time and it supports syncing, it is loaded into the `syncManager` to provide handlers for various lifecycle events.
- See `getEntityRecord` in `packages/core-data/src/resolvers.js`.
- See `syncManager.load()` in this package.
3. **LOCAL CHANGES**: When local changes are made to an entity record, it is applied to the entity's CRDT document, which is synced with peers.
- See `editEntityRecord` in `packages/core-data/src/actions.js`.
- See `syncManager.update()` in this package.
4. **REMOTE CHANGES**: When an entity's CRDT document is updated by a remote peer, changes are extracted and the entity record is updated in the local store.
- See `updateEntityRecord` in this package.

While the Redux actions in `core-data` and the `syncManager` orchestrate this data flow, the behavior of what gets synced is controlled by the entity's `syncConfig`:

- `applyChangesToCRDTDoc` determines how (or if) local changes are applied to the CRDT document.
- `getChangesFromCRDTDoc` determines how (or if) changes from the CRDT document are extracted and applied to the entity record.
- `supports` is a hash that declares support for various sync features, present and future.

An entity's `syncConfig` "owns" the sync behavior of the entity (especially via `applyChangesToCRDTDoc` and `getChangesFromCRDTDoc`) and it should not delegate or leak that responsibility to other parts of the codebase.
Loading
Loading