Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
816e55c
Being adding user info
ingeniumed Jan 15, 2026
f81ed74
Got the user fetch working
ingeniumed Jan 16, 2026
6e3eb80
Added in some test logging
ingeniumed Jan 16, 2026
2836b7e
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 16, 2026
cf05627
Attempting to fix the sync
ingeniumed Jan 16, 2026
2c1d5cf
Add a comment explining the bug
ingeniumed Jan 18, 2026
8772cb2
Add support for selection
ingeniumed Jan 19, 2026
979e04a
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 19, 2026
e68cfde
Ensure the pckakge-lock changes are in
ingeniumed Jan 19, 2026
77cad8f
Add more comments and simplify the user handling
ingeniumed Jan 19, 2026
0e12107
Tweak the exported functions
ingeniumed Jan 19, 2026
1b19ef1
Revert the webpack workaround
ingeniumed Jan 19, 2026
215942c
Fix the type error
ingeniumed Jan 19, 2026
4f37149
ignore types for block editor import
ingeniumed Jan 19, 2026
e9cc4bc
Fix the typo in the constant
ingeniumed Jan 19, 2026
63a11d7
Tweaked the local storage key
ingeniumed Jan 19, 2026
71c7741
Attempting to solve the test failures
ingeniumed Jan 20, 2026
f6a7cc0
Fix the test fialures
ingeniumed Jan 20, 2026
45c439d
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 20, 2026
9200272
Remove a TODO
ingeniumed Jan 20, 2026
a2f7a09
Re-wrote the user selection to be in the core-data, and move the awar…
ingeniumed Jan 21, 2026
d1066e9
Clean up the code
ingeniumed Jan 21, 2026
96c6e6a
Added a todo for local storage
ingeniumed Jan 21, 2026
40bba56
Remove the block-editor fix
ingeniumed Jan 21, 2026
9fb1542
Fix the test using STORE_NAME
ingeniumed Jan 21, 2026
266fd2c
Move awareness implementation details to core-data, and only leave th…
ingeniumed Jan 22, 2026
2d6ca82
Replace undefined awareness test with a mock
ingeniumed Jan 22, 2026
335f32f
Fix the tests failures in resolvers
ingeniumed Jan 22, 2026
4e6bff0
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 22, 2026
731eea5
Improve types and keep WordPress domain knowledge out of sync package…
chriszarate Jan 22, 2026
e8af246
Remove unnecessary exports
chriszarate Jan 22, 2026
daa079a
Rename getAwarenessInstance => getAwareness for symmetry
chriszarate Jan 22, 2026
fa964ed
Remove vestial userInfo reference
chriszarate Jan 22, 2026
ec820f4
Relocate selection types and use YMapWrap
chriszarate Jan 22, 2026
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
2 changes: 2 additions & 0 deletions package-lock.json

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

6 changes: 5 additions & 1 deletion packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export const getEntityRecord =
recordWithTransients,
{
// Handle edits sourced from the sync manager.
editRecord: ( edits ) => {
editRecord: ( edits, options = {} ) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is to allow for core-data and editor to not be used within selection-utils.ts from the sync package. It also means that the awareness instance is always aware about what post type and post ID it's meant for. There's no need to fetch it and verify if its set or not.

if ( ! Object.keys( edits ).length ) {
return;
}
Expand All @@ -206,6 +206,7 @@ export const getEntityRecord =
meta: {
undo: undefined,
},
options,
} );
},
// Get the current entity record (with edits)
Expand All @@ -232,6 +233,9 @@ export const getEntityRecord =
key
);
},
// Get the current user.
getCurrentUser: async () =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I kept this here in the existing handlers. I like this place personally. We could rename it from recordHandlers but honestly, that works well and I'd keep that name.

We can change it if we want to later on.

await resolveSelect.getCurrentUser(),
}
);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/sync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"sideEffects": false,
"dependencies": {
"@types/diff": "7.0.2",
"@wordpress/block-editor": "file:../block-editor",
"@wordpress/data": "file:../data",
"@wordpress/hooks": "file:../hooks",
"@wordpress/undo-manager": "file:../undo-manager",
"@wordpress/url": "file:../url",
Expand Down
47 changes: 40 additions & 7 deletions packages/sync/src/awareness/awareness-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import type * as Y from 'yjs';
/**
* Internal dependencies
*/
import type { ObjectID, ObjectType } from '../types';
import type { ObjectID, ObjectType, RecordHandlers } from '../types';
import type { AwarenessState } from './awareness-state';
import { PostEditorAwarenessState } from './post-editor-awareness-state';
import type { UserInfo, WordPressUserInfo } from './awareness-types';
import { getBrowserName, getNewUserColor } from '../user-utils';

const awarenessInstances: Map< string, AwarenessState > = new Map();

Expand All @@ -26,6 +28,28 @@ function getAwarenessInstance(
return awarenessInstances.get( getAwarenessId( objectType, objectId ) );
}

function getUserInfo(
awareness: AwarenessState,
wpUser: WordPressUserInfo
): UserInfo {
const states = awareness.getStates();
// TODO: There is a timing issue here. The other users aren't yet synced, and as a result the same color could be assigned to multiple users.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chriszarate - this is the timing issue that I mentioned. The other users aren't yet synced and so the colour that's picked doesn't account for someone else using that. Eventually they do sync so it's not the end of the world. We can just have this be changed when a user is added. Besides this, I've not noticed any problems.

My hunch is that, this was always there but due to the way we were instantiating it earlier it wasn't being run into. There was just enough time for that first sync to finish.

@alecgeatches alecgeatches Jan 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd guess that the "workaround" for the current awareness implementation is called here, where we await user data with a short setTimeout before we call setUp(). This is still a race condition, but I'm guessing it's the short delay that allows other user awareness information to get picked up.

I remember this being necessary before syncing:

// getCurrentUser() returns an empty user object for a short time after load.
// In that case, wait and try again.

Have you seen that happen in testing? Either way, a short delay to set up awareness after connecting may be an easy workaround.

@alecgeatches alecgeatches Jan 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tested the current vip-real-time-collaboration plugin awareness implementation locally, and it looks like we typically run the setTimeout( resolve, 100 ) once. So it's just one 100ms delay during setup that seems to do the trick, for local connections at least. If we're going to add an arbitrary deplay, we could start there or bump it up a bit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I had tried the timeout approach and it wasn't working consistently. That's why I mentioned that this was always there but due to the way we instantiated awareness it wasn't being run into. I'm honestly not a fan of timeouts because they are arbitrary, and don't exactly solve the core problem.

I think the better solution would be to identify the properties that can be impacted by this quirk. Then, as users are added (onChange) tweak those properties accordingly. That'd do the trick though the only issue is potentially running into race conditions because multiple awareness instances are doing this at the same time.

That's why for now, I marked this as a TODO so it could be revisited in another PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Noting that I tries this again and it didn't work unfortunately.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Okay! Maybe we could listen for our first "update" event before setting up the local awareness (ideally) or fallback to a timeout in order to make it work consistently with and without other users present. I agree that a timeout alone isn't very robust. I'm fine with pushing this to another PR, since the worst-case seems to be overlapping colors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's another idea I had in mind that I wanted to experiment with. Gonna punt this to another PR as it's not too urgent at the moment given awareness isn't even used in the UI.

const otherUserColors = Array.from( states.entries() )
.filter(
( [ clientId, state ] ) =>
state.userInfo && clientId !== awareness.clientID
)
.map( ( [ , state ] ) => state.userInfo.color )
.filter( Boolean );

return {
...wpUser,
browserType: getBrowserName(),
color: getNewUserColor( otherUserColors ),
enteredAt: Date.now(),
};
}

/**
* Get the post editor awareness instance for the given post ID and post type.
* @param postId Post ID.
Expand All @@ -49,32 +73,41 @@ export function getPostEditorAwareness(

/**
* Create an awareness instance for the given object type and object ID.
* @param objectType Object type.
* @param objectId Object ID.
* @param ydoc Yjs document.
* @param objectType Object type.
* @param objectId Object ID.
* @param ydoc Yjs document.
* @param recordHandlers Record handlers.
* @return Awareness instance.
*/
export async function createAwareness(
objectType: ObjectType,
objectId: ObjectID | null,
ydoc: Y.Doc
ydoc: Y.Doc,
recordHandlers: RecordHandlers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than passing in recordHandlers, can you await the currentUser in the sync manager and pass it in? The current user doesn't change, so it only needs to be done once.

The record handlers are very powerful / dangerous and ideally should not be passed around.

Suggested change
recordHandlers: RecordHandlers
currentUser: User // or whatever type

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think one of my previous commits shows me going back and forth with this for the same reason. I'm fine just passing in the currentUser

): Promise< AwarenessState | undefined > {
if ( objectId && objectType.startsWith( 'postType/' ) ) {
const awareness = new PostEditorAwarenessState( ydoc );
awareness.setUp();

// TODO: Is there still a need to memoize the current user?
const currentUser = await recordHandlers.getCurrentUser();
Comment thread
ingeniumed marked this conversation as resolved.
Outdated
const userInfo = getUserInfo( awareness, currentUser );

awareness.setUp( recordHandlers, userInfo );
awarenessInstances.set(
getAwarenessId( objectType, objectId ),

@ingeniumed ingeniumed Jan 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This allows for the awareness instances to exist within the closure of manager, similar to entity states.

awareness
);

return awareness;
}

return undefined;
}

/**
* Set the current user's connection status in the awareness instance for the given object type and object ID.
*
* TODO: Use this in a generic way with each provider so it doesn't need to be exported externally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@chriszarate - this was from the previous PR that I didn't add a TODO for. We need this for the connected vs disconnected users within the debug data as well as within the UI. I tried to play around with setting it on a provider but I think due to the generic way we set it up I wasn't able to. I've punted it to a follow up PR instead.

*
* @param objectType Object type.
* @param objectId Object ID.
* @param isConnected Connection status.
Expand Down
73 changes: 57 additions & 16 deletions packages/sync/src/awareness/awareness-state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Internal dependencies
*/
import type { UserInfo } from './awareness-types';
import {
TypedAwareness,
type BaseState,
Expand All @@ -9,6 +10,7 @@ import {
} from './awareness-types';
import { getTypedKeys, areMapsEqual } from '../utils';
import { REMOVAL_DELAY_IN_MS } from '../config';
import type { RecordHandlers } from '../types';

type AwarenessClientID = number;

Expand All @@ -28,8 +30,8 @@ abstract class AwarenessWithEqualityChecks<
* trigger rerenders of any subscribed components.
*
* Equality checks are provided by the abstract `equalityFieldChecks` property.
* @param field
* @param value
* @param field - The field to set.
* @param value - The value to set.
*/
public setLocalStateField< FieldName extends string & keyof State >(
field: FieldName,
Expand Down Expand Up @@ -62,9 +64,9 @@ abstract class AwarenessWithEqualityChecks<

/**
* Determine if a field value has changed using the provided equality checks.
* @param field
* @param value1
* @param value2
* @param field - The field to check.
* @param value1 - The first value to compare.
* @param value2 - The second value to compare.
*/
protected isFieldEqual< FieldName extends keyof State >(
field: FieldName,
Expand All @@ -90,8 +92,8 @@ abstract class AwarenessWithEqualityChecks<
/**
* Determine if two states are equal by comparing each field using the
* provided equality checks.
* @param state1
* @param state2
* @param state1 - The first state to compare.
* @param state2 - The second state to compare.
*/
protected isStateEqual( state1: State, state2: State ): boolean {
return [
Expand Down Expand Up @@ -144,13 +146,18 @@ export abstract class AwarenessState<
* value -- even if it hasn't yet been set on the awareness instance.
*/
private myThrottledState: Partial< State > = {};
private throttleTimeouts: Map< string, NodeJS.Timeout > = new Map();

/** CUSTOM METHODS */

/**
* Set up.
* Set up the awareness state.
* @param _recordHandlers - Record handlers.
* @param userInfo - User info.
*/
public setUp(): void {
public setUp( _recordHandlers: RecordHandlers, userInfo: UserInfo ): void {
this.setLocalStateField( 'userInfo', userInfo );

this.on(
'change',
( { added, removed, updated }: AwarenessStateChange ) => {
Expand Down Expand Up @@ -183,7 +190,7 @@ export abstract class AwarenessState<

/**
* Allow external code to subscribe to awareness state changes.
* @param callback
* @param callback - The callback to subscribe to.
*/
public onStateChange(
callback: ( newState: EnhancedState< State >[] ) => void
Expand All @@ -197,9 +204,37 @@ export abstract class AwarenessState<
};
}

/**
* Set a local state field on an awareness document with throttle. See caveats
* of this.setLocalStateField.
* @param field - The field to set.
* @param value - The value to set.
* @param wait - The wait time in milliseconds.
*/
public setThrottledLocalStateField<
FieldName extends string & keyof State,
>( field: FieldName, value: State[ FieldName ], wait: number ): void {
this.setLocalStateField( field, value );

this.throttleTimeouts.set(
field,
setTimeout( () => {
this.throttleTimeouts.delete( field );
if ( this.myThrottledState[ field ] ) {
this.setLocalStateField(
field,
this.myThrottledState[ field ]
);

delete this.myThrottledState[ field ];
}
}, wait )
);
}

/**
* Set the current user's connection status as awareness state.
* @param isConnected
* @param isConnected - The connection status.
*/
public setConnectionStatus( isConnected: boolean ): void {
if ( isConnected ) {
Expand All @@ -213,7 +248,7 @@ export abstract class AwarenessState<

/**
* Update all subscribed listeners with the latest awareness state.
* @param forceUpdate
* @param forceUpdate - Whether to force an update.
*/
protected updateSubscribers( forceUpdate = false ): void {
if ( ! this.stateSubscriptions.length ) {
Expand All @@ -228,8 +263,15 @@ export abstract class AwarenessState<
] );

const updatedStates = new Map< number, EnhancedState< State > >(
[ ...this.disconnectedUsers, ...states.keys() ].map(
( clientId ) => {
[ ...this.disconnectedUsers, ...states.keys() ]
.filter( ( clientId ) => {
// Exclude any users without `userInfo`.
// This can happen from the Yjs inspector, which joins the awareness
// state without providing user data.
return Boolean( this.seenStates.get( clientId )?.userInfo );
} )
.map( ( clientId ) => {
// The filter above ensures that seenStates has the clientId.
const rawState: State = this.seenStates.get( clientId )!;

const isConnected =
Expand All @@ -247,8 +289,7 @@ export abstract class AwarenessState<
};

return [ clientId, state ];
}
)
} )
);

if ( ! forceUpdate ) {
Expand Down
67 changes: 61 additions & 6 deletions packages/sync/src/awareness/awareness-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Awareness } from 'y-protocols/awareness';

import { getRecordValue } from '../utils';
import type { SelectionState } from '../selection-utils';

/**
* Extended Awareness class with typed state accessors.
Expand Down Expand Up @@ -37,14 +38,39 @@ export class TypedAwareness< State extends BaseState > extends Awareness {
}
}

/**
* This base user info is a subset of the User interface from @wordpress/core-data.
*
* In order to avoid circular dependencies, we define it here instead of importing
* the User interface from @wordpress/core-data.
*
* The avatarUrl is an additional field that is not part of the User interface.
*/
export interface WordPressUserInfo {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is different from the existing RTC code. I had to re-define it due to the circular dependency it would cause.

id: number;
name: string;
slug: string;
avatar_urls: Record< string, string >;
}

/**
* The user info interface extends the base user info with additional fields used for presence
* indicators.
*/
export interface UserInfo extends WordPressUserInfo {
browserType: string;
color: string;
enteredAt: number;
}

/**
* This base state represents the presence of the user. We expect it to be
* extended to include additional state describing the user's current activity.
* This state must be serializable and compact.
*
* TODO: Add in the user information.
*/
export interface BaseState {}
export interface BaseState {
userInfo: UserInfo;
}

/**
* An enhanced state includes additional metadata about the user's connection
Expand All @@ -56,15 +82,44 @@ export type EnhancedState< State extends BaseState > = State & {
isMe: boolean;
};

/**
* A block selection object.
*
* In order to avoid circular dependencies, we define it here instead of importing
* the WPBlockSelection interface from @wordpress/editor.
*/
export type WPBlockSelection = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is different from the existing RTC code. I had to re-define it due to the circular dependency it would cause.

@alecgeatches alecgeatches Jan 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-defining types is something we seem to do a lot. It doesn't seem great but I don't have a solution. Commenting to hear if @chriszarate does.

/**
* A block client ID.
*/
clientId: string;
/**
* A block attribute key.
*/
attributeKey: string;
/**
* An attribute value offset, based on the rich
* text value. See `wp.richText.create`.
*/
offset: number;
};

export type EqualityFieldCheck<
State extends BaseState,
FieldName extends keyof State,
> = ( value1?: State[ FieldName ], value2?: State[ FieldName ] ) => boolean;

/**
* The editor state includes information about the user's current selection.
*/
export interface EditorState {
selection: SelectionState;
}

/**
* The post editor state extends the base state with information used to render
* presence indicators in the post editor.
*
* TODO: Add in the presence indicators.
*/
export interface PostEditorState extends BaseState {}
export interface PostEditorState extends BaseState {
editorState?: EditorState;
}
Loading
Loading