From 870228e14df76ba3b4a061eaa0a324d061b68924 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Fri, 27 Feb 2026 00:46:00 +0530 Subject: [PATCH 01/14] RTC: Flush deferred Y.Doc updates before CRDT serialization on save When sync is enabled, Y.Doc updates are deferred via setTimeout(0). saveEntityRecord was calling __unstablePrePersist without yielding first, so the serialized _crdt_document was stale. On reload this caused unnecessary reconciliation saves. Yield to the event loop before __unstablePrePersist when syncConfig is present so pending Y.Doc updates are flushed before serialization. --- packages/core-data/src/actions.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index a4e9e4d3505f78..e38f5f160fd808 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -771,6 +771,15 @@ export const saveEntityRecord = } else { let edits = record; if ( entityConfig.__unstablePrePersist ) { + // When sync (collaboration) is enabled, Y.Doc updates + // are deferred via setTimeout(0). Yield to the event + // loop so pending updates are flushed before + // __unstablePrePersist serializes the CRDT document. + if ( entityConfig.syncConfig ) { + await new Promise( ( resolve ) => + setTimeout( resolve, 0 ) + ); + } edits = { ...edits, ...( await entityConfig.__unstablePrePersist( From c4acc1bf0bc2d1aac815b38349dbb271c439c6d5 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 24 Feb 2026 15:26:18 +0530 Subject: [PATCH 02/14] RTC: Add session activity notifications Collaborators had no way to know when others joined, left, or saved the post. This adds snackbar notices for all four events, broadcast via a new `lastSaveEvent` field on the Yjs awareness state. Notification types can be toggled via the `editor.collaborationNotifications` WordPress filter. --- .../src/awareness/post-editor-awareness.ts | 4 + packages/core-data/src/awareness/types.ts | 10 + .../hooks/use-post-editor-awareness-state.ts | 35 +- packages/core-data/src/private-apis.js | 2 + .../test/use-collaborator-notifications.ts | 620 ++++++++++++++++++ .../use-collaborator-notifications.ts | 278 ++++++++ .../src/components/editor-interface/index.js | 5 + 7 files changed, 953 insertions(+), 1 deletion(-) create mode 100644 packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts create mode 100644 packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts diff --git a/packages/core-data/src/awareness/post-editor-awareness.ts b/packages/core-data/src/awareness/post-editor-awareness.ts index f97341983d9b66..c1a773d95ce092 100644 --- a/packages/core-data/src/awareness/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/post-editor-awareness.ts @@ -36,6 +36,10 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { protected equalityFieldChecks = { ...baseEqualityFieldChecks, editorState: this.areEditorStatesEqual, + lastSaveEvent: ( + a?: PostEditorState[ 'lastSaveEvent' ], + b?: PostEditorState[ 'lastSaveEvent' ] + ) => a?.savedAt === b?.savedAt, }; public constructor( diff --git a/packages/core-data/src/awareness/types.ts b/packages/core-data/src/awareness/types.ts index eee5a7036da3f2..84ac98be3136d8 100644 --- a/packages/core-data/src/awareness/types.ts +++ b/packages/core-data/src/awareness/types.ts @@ -33,12 +33,22 @@ export interface EditorState { selection: SelectionState; } +/** + * Represents a save event broadcast via the awareness system. + * `status` is the WordPress post status at the time of the save. + */ +export interface SaveEvent { + savedAt: number; + status: string; +} + /** * The post editor state extends the base state with information used to render * presence indicators in the post editor. */ export interface PostEditorState extends BaseState { editorState?: EditorState; + lastSaveEvent?: SaveEvent; } /** diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index b055cce8dcb64c..43480f0f2bd1b5 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useEffect, useState } from '@wordpress/element'; +import { useCallback, useEffect, useState } from '@wordpress/element'; /** * Internal dependencies @@ -156,3 +156,36 @@ export function useIsDisconnected( return usePostEditorAwarenessState( postId, postType ) .isCurrentCollaboratorDisconnected; } + +/** + * Hook that returns a callback to broadcast a save event to all collaborators + * via the awareness system. When called, it sets the `lastSaveEvent` field on + * the local awareness state so that other collaborators can display a notification. + * + * @param postId The ID of the post. + * @param postType The type of the post. + */ +export function useBroadcastSaveEvent( + postId: number | null, + postType: string | null +): ( status: string ) => void { + return useCallback( + ( status: string ) => { + if ( null === postId || null === postType ) { + return; + } + + const awareness = + getSyncManager()?.getAwareness< PostEditorAwareness >( + `postType/${ postType }`, + postId.toString() + ); + + awareness?.setLocalStateField( 'lastSaveEvent', { + savedAt: Date.now(), + status, + } ); + }, + [ postId, postType ] + ); +} diff --git a/packages/core-data/src/private-apis.js b/packages/core-data/src/private-apis.js index 5f6e1919f3a459..b4a1a06ebe74b2 100644 --- a/packages/core-data/src/private-apis.js +++ b/packages/core-data/src/private-apis.js @@ -6,6 +6,7 @@ import { RECEIVE_INTERMEDIATE_RESULTS } from './utils'; import { useActiveCollaborators, useResolvedSelection, + useBroadcastSaveEvent, } from './hooks/use-post-editor-awareness-state'; import { lock } from './lock-unlock'; import { retrySyncConnection } from './sync'; @@ -17,4 +18,5 @@ lock( privateApis, { retrySyncConnection, useActiveCollaborators, useResolvedSelection, + useBroadcastSaveEvent, } ); diff --git a/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts new file mode 100644 index 00000000000000..69ad76eae6228c --- /dev/null +++ b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts @@ -0,0 +1,620 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { useCollaboratorNotifications } from '../use-collaborator-notifications'; + +// --- Mocks --- + +const mockCreateNotice = jest.fn(); +const mockBroadcastSaveEvent = jest.fn(); +let mockActiveCollaborators: any[] = []; +let mockEditorState = { + isSaving: false, + isAutosaving: false, + didSaveSucceed: true, + postStatus: 'draft', + isCollaborationEnabled: true, +}; + +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn(), + useDispatch: jest.fn(), +} ) ); + +jest.mock( '@wordpress/hooks', () => ( { + applyFilters: jest.fn( + ( _hook: string, defaultValue: unknown ) => defaultValue + ), +} ) ); + +jest.mock( '@wordpress/notices', () => ( { + store: 'core/notices', +} ) ); + +// Mock the editor store to prevent deep import chain (blocks, rich-text, etc.) +jest.mock( '../../../store', () => ( { + store: 'core/editor', +} ) ); + +// Mock the private APIs and unlock mechanism +jest.mock( '@wordpress/core-data', () => ( { + privateApis: {}, +} ) ); + +jest.mock( '../../../lock-unlock', () => ( { + unlock: jest.fn( () => ( { + useActiveCollaborators: jest.fn( () => mockActiveCollaborators ), + useBroadcastSaveEvent: jest.fn( () => mockBroadcastSaveEvent ), + } ) ), +} ) ); + +// --- Helpers --- + +const BASE_ENTERED_AT = 1704067200000; + +function makeCollaborator( overrides: Record< string, unknown > = {} ) { + return { + clientId: 1, + isMe: false, + isConnected: true, + collaboratorInfo: { + id: 100, + name: 'Alice', + slug: 'alice', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 1000, + }, + ...overrides, + }; +} + +function makeMe( overrides: Record< string, unknown > = {} ) { + return makeCollaborator( { + clientId: 999, + isMe: true, + collaboratorInfo: { + id: 1, + name: 'Me', + slug: 'me', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 5000, // joined later than Alice + }, + ...overrides, + } ); +} + +// --- Setup --- + +function buildMockSelect() { + return () => ( { + isSavingPost: () => mockEditorState.isSaving, + isAutosavingPost: () => mockEditorState.isAutosaving, + didPostSaveRequestSucceed: () => mockEditorState.didSaveSucceed, + getCurrentPostAttribute: ( attr: string ) => + attr === 'status' ? mockEditorState.postStatus : undefined, + isCollaborationEnabledForCurrentPost: () => + mockEditorState.isCollaborationEnabled, + } ); +} + +beforeEach( () => { + mockActiveCollaborators = []; + mockEditorState = { + isSaving: false, + isAutosaving: false, + didSaveSucceed: true, + postStatus: 'draft', + isCollaborationEnabled: true, + }; + mockCreateNotice.mockClear(); + mockBroadcastSaveEvent.mockClear(); + ( applyFilters as jest.Mock ).mockImplementation( + ( _hook: string, defaultValue: unknown ) => defaultValue + ); + ( useSelect as jest.Mock ).mockImplementation( ( selector: Function ) => + selector( buildMockSelect() ) + ); + ( useDispatch as jest.Mock ).mockReturnValue( { + createNotice: mockCreateNotice, + } ); +} ); + +// --- Tests --- + +describe( 'useCollaboratorNotifications', () => { + describe( 'initial mount', () => { + it( 'does not fire join notifications for collaborators already present on mount', () => { + mockActiveCollaborators = [ makeMe(), makeCollaborator() ]; + + renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire any notification when no collaborators are present', () => { + mockActiveCollaborators = []; + + renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire join notifications when collaborators load after an initially empty state', () => { + // Simulates the store hydrating: first render has no collaborators, + // second render receives the full list. + mockActiveCollaborators = []; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ makeMe(), makeCollaborator() ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'collaborator join notifications', () => { + it( 'does not fire a join notification for the current user', () => { + mockActiveCollaborators = []; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ makeMe() ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'skips join notification for collaborators who joined before current user', () => { + // Alice joined BEFORE the current user (smaller enteredAt) + const me = makeMe(); // enteredAt: BASE_ENTERED_AT + 5000 + const aliceJoinedFirst = makeCollaborator( { + collaboratorInfo: { + id: 100, + name: 'Alice', + slug: 'alice', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 1000, // joined earlier than me + }, + } ); + + mockActiveCollaborators = [ me ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice appears in the state — but she was there before us + mockActiveCollaborators = [ me, aliceJoinedFirst ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'fires join notification for a collaborator who joined after current user', () => { + const me = makeMe(); // enteredAt: BASE_ENTERED_AT + 5000 + const bobJoinedAfter = makeCollaborator( { + clientId: 2, + collaboratorInfo: { + id: 200, + name: 'Bob', + slug: 'bob', + avatar_urls: {}, + browserType: 'Firefox', + enteredAt: BASE_ENTERED_AT + 10000, // joined after me + }, + } ); + + mockActiveCollaborators = [ me ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ me, bobJoinedAfter ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Bob has joined the post.', + expect.objectContaining( { + id: 'collab-user-entered-200', + } ) + ); + } ); + } ); + + describe( 'collaborator leave notifications', () => { + it( 'fires a leave notification when a collaborator disconnects (isConnected → false)', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice disconnects — still in the list but greyed out. + mockActiveCollaborators = [ + makeMe(), + { ...alice, isConnected: false }, + ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Alice has left the post.', + expect.objectContaining( { + type: 'snackbar', + isDismissible: false, + id: 'collab-user-exited-100', + } ) + ); + } ); + + it( 'does not fire a duplicate leave notification when a disconnected collaborator is removed from the list', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice disconnects (greyed out). + mockActiveCollaborators = [ + makeMe(), + { ...alice, isConnected: false }, + ]; + rerender(); + mockCreateNotice.mockClear(); + + // After the 5s delay Alice is fully removed from the list. + mockActiveCollaborators = [ makeMe() ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'fires a leave notification when a connected collaborator is removed from the list directly', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice disappears from the list without going through isConnected=false + // (e.g. polling detects the disconnect and removes in one update). + mockActiveCollaborators = [ makeMe() ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Alice has left the post.', + expect.objectContaining( { + type: 'snackbar', + isDismissible: false, + id: 'collab-user-exited-100', + } ) + ); + } ); + + it( 'does not fire a leave notification for the current user', () => { + const me = makeMe(); + mockActiveCollaborators = [ me, makeCollaborator() ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // "Me" disconnects + mockActiveCollaborators = [ + { ...me, isConnected: false }, + makeCollaborator(), + ]; + rerender(); + + // Should not notify about self + const selfLeaveCall = mockCreateNotice.mock.calls.find( + ( [ , message ] ) => message.includes( 'Me' ) + ); + expect( selfLeaveCall ).toBeUndefined(); + } ); + } ); + + describe( 'post updated notifications', () => { + it( 'fires a post updated notification when a collaborator saves (draft)', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice saves the post (draft status) + mockActiveCollaborators = [ + makeMe(), + { + ...alice, + lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, + }, + ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Draft saved by Alice.', + expect.objectContaining( { + type: 'snackbar', + isDismissible: false, + id: 'collab-post-updated-100', + } ) + ); + } ); + + it( 'fires a post updated notification with "Post updated" for published status', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ + makeMe(), + { + ...alice, + lastSaveEvent: { + savedAt: Date.now(), + status: 'publish', + }, + }, + ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Post updated by Alice.', + expect.objectContaining( { + id: 'collab-post-updated-100', + } ) + ); + } ); + + it( 'does not fire a post updated notification on the initial mount even with a saveEvent', () => { + mockActiveCollaborators = [ + makeMe(), + { + ...makeCollaborator(), + lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, + }, + ]; + + renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire a post updated notification for a newly appeared collaborator with a historical save event', () => { + const me = makeMe(); + mockActiveCollaborators = [ me ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice appears for the first time carrying a save event from + // before our session — should not trigger a notification. + mockActiveCollaborators = [ + me, + { + ...makeCollaborator( { + collaboratorInfo: { + id: 100, + name: 'Alice', + slug: 'alice', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 10000, + }, + } ), + lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, + }, + ]; + rerender(); + + // The join notification fires, but no save notification. + expect( mockCreateNotice ).toHaveBeenCalledTimes( 1 ); + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Alice has joined the post.', + expect.objectContaining( { + id: 'collab-user-entered-100', + } ) + ); + } ); + + it( 'does not fire duplicate notifications for the same savedAt timestamp', () => { + const savedAt = Date.now(); + const alice = { + ...makeCollaborator(), + lastSaveEvent: { savedAt, status: 'draft' }, + }; + + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Simulate a re-render with the same saveEvent + mockActiveCollaborators = [ makeMe(), { ...alice } ]; + rerender(); + + // The initial mount should record it, not notify. + // A subsequent rerender with the same savedAt should not notify either. + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'WordPress filter: editor.collaborationNotifications', () => { + it( 'suppresses join notifications when userEntered is false', () => { + ( applyFilters as jest.Mock ).mockReturnValue( { + userEntered: false, + userExited: true, + postUpdated: true, + } ); + + mockActiveCollaborators = [ makeMe() ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ + makeMe(), + makeCollaborator( { + collaboratorInfo: { + id: 100, + name: 'Alice', + slug: 'alice', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 10000, + }, + } ), + ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'suppresses leave notifications when userExited is false', () => { + ( applyFilters as jest.Mock ).mockReturnValue( { + userEntered: true, + userExited: false, + postUpdated: true, + } ); + + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ makeMe() ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'suppresses post updated notifications when postUpdated is false', () => { + ( applyFilters as jest.Mock ).mockReturnValue( { + userEntered: true, + userExited: true, + postUpdated: false, + } ); + + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ + makeMe(), + { + ...alice, + lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, + }, + ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'save event broadcasting', () => { + it( 'broadcasts save event when save transitions from in-progress to complete', () => { + mockActiveCollaborators = []; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Save starts + mockEditorState = { + ...mockEditorState, + isSaving: true, + isAutosaving: false, + }; + rerender(); + + // Save completes + mockEditorState = { + ...mockEditorState, + isSaving: false, + isAutosaving: false, + didSaveSucceed: true, + postStatus: 'draft', + }; + rerender(); + + expect( mockBroadcastSaveEvent ).toHaveBeenCalledWith( 'draft' ); + } ); + + it( 'does not broadcast save event for autosaves', () => { + mockActiveCollaborators = []; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Autosave starts + mockEditorState = { + ...mockEditorState, + isSaving: true, + isAutosaving: true, + }; + rerender(); + + // Autosave completes + mockEditorState = { + ...mockEditorState, + isSaving: false, + isAutosaving: false, + }; + rerender(); + + expect( mockBroadcastSaveEvent ).not.toHaveBeenCalled(); + } ); + + it( 'does not broadcast when collaboration is not enabled', () => { + mockEditorState = { + ...mockEditorState, + isCollaborationEnabled: false, + }; + mockActiveCollaborators = []; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockEditorState = { + ...mockEditorState, + isSaving: true, + isCollaborationEnabled: false, + }; + rerender(); + + mockEditorState = { + ...mockEditorState, + isSaving: false, + }; + rerender(); + + expect( mockBroadcastSaveEvent ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts new file mode 100644 index 00000000000000..f36584357c2d9f --- /dev/null +++ b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts @@ -0,0 +1,278 @@ +/** + * WordPress dependencies + */ +import { usePrevious } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect, useMemo } from '@wordpress/element'; +import { applyFilters } from '@wordpress/hooks'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { + privateApis, + type PostEditorAwarenessState, +} from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editorStore } from '../../store'; + +const { useActiveCollaborators, useBroadcastSaveEvent } = unlock( privateApis ); + +/** + * Notice IDs for each notification type. Using stable IDs prevents duplicate + * notices if the same event is processed more than once. + */ +const NOTIFICATION_TYPE = { + POST_UPDATED: 'collab-post-updated', + USER_ENTERED: 'collab-user-entered', + USER_EXITED: 'collab-user-exited', +} as const; + +interface CollaborationNotificationsConfig { + userEntered: boolean; + userExited: boolean; + postUpdated: boolean; +} + +const DEFAULT_NOTIFICATIONS_CONFIG: CollaborationNotificationsConfig = { + userEntered: true, + userExited: true, + postUpdated: true, +}; + +/** + * Returns the snackbar message for a post updated notification based on post status. + * + * @param name Display name of the collaborator who saved. + * @param status WordPress post status at the time of save. + */ +function getPostUpdatedMessage( name: string, status: string ): string { + const isPublished = [ 'publish', 'private', 'future' ].includes( status ); + if ( isPublished ) { + /* translators: %s: collaborator display name */ + return sprintf( __( 'Post updated by %s.' ), name ); + } + /* translators: %s: collaborator display name */ + return sprintf( __( 'Draft saved by %s.' ), name ); +} + +/** + * Hook that watches for collaborator join/leave events and remote save events, + * dispatching snackbar notices accordingly. + * + * Notification types can be enabled/disabled via the + * `editor.collaborationNotifications` WordPress filter. + * + * @param postId The ID of the post being edited. + * @param postType The post type of the post being edited. + */ +export function useCollaboratorNotifications( + postId: number | null, + postType: string | null +): void { + const activeCollaborators = useActiveCollaborators( + postId, + postType + ) as PostEditorAwarenessState[]; + + const broadcastSaveEvent = useBroadcastSaveEvent( postId, postType ); + + const { + isSaving, + isAutosaving, + didSaveSucceed, + postStatus, + isCollaborationEnabled, + } = useSelect( ( select ) => { + const editorSel = select( editorStore ); + return { + isSaving: editorSel.isSavingPost(), + isAutosaving: editorSel.isAutosavingPost(), + didSaveSucceed: editorSel.didPostSaveRequestSucceed(), + postStatus: editorSel.getCurrentPostAttribute( 'status' ) as + | string + | undefined, + isCollaborationEnabled: + editorSel.isCollaborationEnabledForCurrentPost(), + }; + }, [] ); + + const { createNotice } = useDispatch( noticesStore ); + + const notificationsConfig = useMemo( + () => + applyFilters( + 'editor.collaborationNotifications', + DEFAULT_NOTIFICATIONS_CONFIG + ) as CollaborationNotificationsConfig, + [] + ); + + const prevCollaborators = usePrevious( activeCollaborators ); + const prevIsSaving = usePrevious( isSaving ); + const prevIsAutosaving = usePrevious( isAutosaving ); + + // Broadcast our own saves to collaborators via awareness. + useEffect( () => { + if ( + prevIsSaving && + ! isSaving && + ! prevIsAutosaving && + didSaveSucceed && + isCollaborationEnabled && + postStatus + ) { + broadcastSaveEvent( postStatus ); + } + }, [ + isSaving, + prevIsSaving, + prevIsAutosaving, + didSaveSucceed, + postStatus, + isCollaborationEnabled, + broadcastSaveEvent, + ] ); + + /* + * Detect collaborator joins, leaves, and remote saves. + */ + useEffect( () => { + if ( ! isCollaborationEnabled ) { + return; + } + + /* + * On first render usePrevious returns undefined. On subsequent renders + * the list may still be empty while the store hydrates. In both cases, + * skip to avoid spurious "X joined" toasts for users already present. + */ + if ( ! prevCollaborators || prevCollaborators.length === 0 ) { + return; + } + + /* + * Convenience wrapper to keep notice calls concise. + */ + function notify( noticeId: string, message: string ) { + void createNotice( 'info', message, { + id: noticeId, + type: 'snackbar', + isDismissible: false, + } ); + } + + const prevMap = new Map< number, PostEditorAwarenessState >( + prevCollaborators.map( ( c ) => [ c.clientId, c ] ) + ); + const newMap = new Map< number, PostEditorAwarenessState >( + activeCollaborators.map( ( c ) => [ c.clientId, c ] ) + ); + + /* + * Detect joins: new clientIds that weren't in the previous state. + */ + if ( notificationsConfig.userEntered ) { + const me = activeCollaborators.find( ( c ) => c.isMe ); + + for ( const [ clientId, collaborator ] of newMap ) { + if ( prevMap.has( clientId ) || collaborator.isMe ) { + continue; + } + + /* + * Skip collaborators who were present before the current user + * joined. Their enteredAt is earlier than ours, meaning we're + * the newcomer. + */ + if ( + me && + collaborator.collaboratorInfo.enteredAt < + me.collaboratorInfo.enteredAt + ) { + continue; + } + + notify( + `${ NOTIFICATION_TYPE.USER_ENTERED }-${ collaborator.collaboratorInfo.id }`, + sprintf( + /* translators: %s: collaborator display name */ + __( '%s has joined the post.' ), + collaborator.collaboratorInfo.name + ) + ); + } + } + + /* + * Detect leaves by iterating the previous collaborator list. A leave + * notification fires when a previously-connected collaborator either: + * - transitions to isConnected=false (greyed-out in the UI), or + * - disappears from the list entirely while still connected. + * Already-disconnected collaborators that are later removed from the + * list (after the 5 s delay) are silently ignored. + */ + if ( notificationsConfig.userExited ) { + for ( const [ clientId, prevCollab ] of prevMap ) { + if ( prevCollab.isMe || ! prevCollab.isConnected ) { + continue; + } + + const newCollab = newMap.get( clientId ); + if ( newCollab?.isConnected ) { + continue; + } + + notify( + `${ NOTIFICATION_TYPE.USER_EXITED }-${ prevCollab.collaboratorInfo.id }`, + sprintf( + /* translators: %s: collaborator display name */ + __( '%s has left the post.' ), + prevCollab.collaboratorInfo.name + ) + ); + } + } + + // Detect remote save events from other collaborators. + if ( notificationsConfig.postUpdated ) { + for ( const [ clientId, collaborator ] of newMap ) { + if ( collaborator.isMe || ! collaborator.lastSaveEvent ) { + continue; + } + + const prevCollab = prevMap.get( clientId ); + + /* + * Only notify for save events from collaborators we already + * knew about. A newly appeared collaborator may carry a + * historical save event that predates our session. + */ + if ( ! prevCollab ) { + continue; + } + + const prevSavedAt = prevCollab.lastSaveEvent?.savedAt; + const { savedAt, status } = collaborator.lastSaveEvent; + + if ( savedAt > ( prevSavedAt ?? 0 ) ) { + notify( + `${ NOTIFICATION_TYPE.POST_UPDATED }-${ collaborator.collaboratorInfo.id }`, + getPostUpdatedMessage( + collaborator.collaboratorInfo.name, + status + ) + ); + } + } + } + }, [ + activeCollaborators, + prevCollaborators, + isCollaborationEnabled, + notificationsConfig, + createNotice, + ] ); +} diff --git a/packages/editor/src/components/editor-interface/index.js b/packages/editor/src/components/editor-interface/index.js index f6ab27d02652c2..080962691a4e1c 100644 --- a/packages/editor/src/components/editor-interface/index.js +++ b/packages/editor/src/components/editor-interface/index.js @@ -27,6 +27,7 @@ import InserterSidebar from '../inserter-sidebar'; import ListViewSidebar from '../list-view-sidebar'; import { RevisionsHeader, RevisionsCanvas } from '../post-revisions-preview'; import { CollaboratorsOverlay } from '../collaborators-overlay'; +import { useCollaboratorNotifications } from '../collaborators-presence/use-collaborator-notifications'; import SavePublishPanels from '../save-publish-panels'; import TextEditor from '../text-editor'; import VisualEditor from '../visual-editor'; @@ -122,6 +123,10 @@ export default function EditorInterface( { isRevisionsMode: _isRevisionsMode(), }; }, [] ); + // Runs unconditionally so join/leave/save notifications are dispatched + // regardless of viewport width or whether the header centre area is visible. + useCollaboratorNotifications( postId, postType ); + const isLargeViewport = useViewportMatch( 'medium' ); const secondarySidebarLabel = isListViewOpened ? __( 'Document Overview' ) From c8310a3c7f3d811ac348d3fa6d7d866d10cb168e Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 24 Feb 2026 20:40:59 +0530 Subject: [PATCH 03/14] RTC: Fix collaboration presence E2E test --- .../editor/collaboration/collaboration-presence.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts index 99b0e039252b80..3ec39bc0ad3bc9 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts @@ -43,6 +43,10 @@ test.describe( 'Collaboration - Presence', () => { await presenceButton.click(); // The popover should list the second collaborator by name. - await expect( page.getByText( 'Test Collaborator' ) ).toBeVisible(); + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { + hasText: 'Test Collaborator', + } ) + ).toBeVisible(); } ); } ); From 629a064232ffdc803a0d511939e3427bdea9d4f5 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Wed, 25 Feb 2026 17:54:26 +0530 Subject: [PATCH 04/14] RTC: Use CRDT state map for save notifications Replace custom awareness-based save broadcasting with the existing CRDT state map already written by markEntityAsSaved in @wordpress/sync. --- .../src/awareness/post-editor-awareness.ts | 4 - packages/core-data/src/awareness/types.ts | 10 - .../hooks/use-post-editor-awareness-state.ts | 81 ++++-- packages/core-data/src/private-apis.js | 4 +- .../test/use-collaborator-notifications.ts | 247 ++++++------------ .../use-collaborator-notifications.ts | 116 ++++---- 6 files changed, 189 insertions(+), 273 deletions(-) diff --git a/packages/core-data/src/awareness/post-editor-awareness.ts b/packages/core-data/src/awareness/post-editor-awareness.ts index c1a773d95ce092..f97341983d9b66 100644 --- a/packages/core-data/src/awareness/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/post-editor-awareness.ts @@ -36,10 +36,6 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { protected equalityFieldChecks = { ...baseEqualityFieldChecks, editorState: this.areEditorStatesEqual, - lastSaveEvent: ( - a?: PostEditorState[ 'lastSaveEvent' ], - b?: PostEditorState[ 'lastSaveEvent' ] - ) => a?.savedAt === b?.savedAt, }; public constructor( diff --git a/packages/core-data/src/awareness/types.ts b/packages/core-data/src/awareness/types.ts index 84ac98be3136d8..eee5a7036da3f2 100644 --- a/packages/core-data/src/awareness/types.ts +++ b/packages/core-data/src/awareness/types.ts @@ -33,22 +33,12 @@ export interface EditorState { selection: SelectionState; } -/** - * Represents a save event broadcast via the awareness system. - * `status` is the WordPress post status at the time of the save. - */ -export interface SaveEvent { - savedAt: number; - status: string; -} - /** * The post editor state extends the base state with information used to render * presence indicators in the post editor. */ export interface PostEditorState extends BaseState { editorState?: EditorState; - lastSaveEvent?: SaveEvent; } /** diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index 43480f0f2bd1b5..c90d61dfe1286e 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -1,7 +1,8 @@ /** * External dependencies */ -import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; +import type { Y } from '@wordpress/sync'; /** * Internal dependencies @@ -157,35 +158,69 @@ export function useIsDisconnected( .isCurrentCollaboratorDisconnected; } +export interface PostSaveEvent { + savedAt: number; + savedByClientId: number; +} + /** - * Hook that returns a callback to broadcast a save event to all collaborators - * via the awareness system. When called, it sets the `lastSaveEvent` field on - * the local awareness state so that other collaborators can display a notification. + * Hook that subscribes to the CRDT state map and returns the most recent + * save event (timestamp + client ID). The state map is updated by + * `markEntityAsSaved` in `@wordpress/sync` * * @param postId The ID of the post. * @param postType The type of the post. */ -export function useBroadcastSaveEvent( +export function useLastPostSave( postId: number | null, postType: string | null -): ( status: string ) => void { - return useCallback( - ( status: string ) => { - if ( null === postId || null === postType ) { - return; +): PostSaveEvent | null { + const [ lastSave, setLastSave ] = useState< PostSaveEvent | null >( null ); + + useEffect( () => { + if ( null === postId || null === postType ) { + setLastSave( null ); + return; + } + + const awareness = getSyncManager()?.getAwareness< PostEditorAwareness >( + `postType/${ postType }`, + postId.toString() + ); + + if ( ! awareness ) { + setLastSave( null ); + return; + } + + const stateMap = awareness.doc.getMap( 'state' ); + + // Only notify for saves that occur after the observer is + // set up. This prevents false notifications when the Y.Doc + // syncs historical state on page load or peer reconnect. + const setupTime = Date.now(); + + const observer = ( event: Y.YMapEvent< unknown > ) => { + if ( event.keysChanged.has( 'savedAt' ) ) { + const savedAt = stateMap.get( 'savedAt' ) as number; + const savedByClientId = stateMap.get( 'savedBy' ) as number; + + if ( + typeof savedAt === 'number' && + typeof savedByClientId === 'number' && + savedAt > setupTime + ) { + setLastSave( { savedAt, savedByClientId } ); + } } + }; + + stateMap.observe( observer ); + + return () => { + stateMap.unobserve( observer ); + }; + }, [ postId, postType ] ); - const awareness = - getSyncManager()?.getAwareness< PostEditorAwareness >( - `postType/${ postType }`, - postId.toString() - ); - - awareness?.setLocalStateField( 'lastSaveEvent', { - savedAt: Date.now(), - status, - } ); - }, - [ postId, postType ] - ); + return lastSave; } diff --git a/packages/core-data/src/private-apis.js b/packages/core-data/src/private-apis.js index b4a1a06ebe74b2..bac5a347682ec7 100644 --- a/packages/core-data/src/private-apis.js +++ b/packages/core-data/src/private-apis.js @@ -6,7 +6,7 @@ import { RECEIVE_INTERMEDIATE_RESULTS } from './utils'; import { useActiveCollaborators, useResolvedSelection, - useBroadcastSaveEvent, + useLastPostSave, } from './hooks/use-post-editor-awareness-state'; import { lock } from './lock-unlock'; import { retrySyncConnection } from './sync'; @@ -18,5 +18,5 @@ lock( privateApis, { retrySyncConnection, useActiveCollaborators, useResolvedSelection, - useBroadcastSaveEvent, + useLastPostSave, } ); diff --git a/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts index 69ad76eae6228c..0bd085eb83cd0e 100644 --- a/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts +++ b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts @@ -17,12 +17,10 @@ import { useCollaboratorNotifications } from '../use-collaborator-notifications' // --- Mocks --- const mockCreateNotice = jest.fn(); -const mockBroadcastSaveEvent = jest.fn(); let mockActiveCollaborators: any[] = []; +let mockLastPostSave: { savedAt: number; savedByClientId: number } | null = + null; let mockEditorState = { - isSaving: false, - isAutosaving: false, - didSaveSucceed: true, postStatus: 'draft', isCollaborationEnabled: true, }; @@ -55,7 +53,7 @@ jest.mock( '@wordpress/core-data', () => ( { jest.mock( '../../../lock-unlock', () => ( { unlock: jest.fn( () => ( { useActiveCollaborators: jest.fn( () => mockActiveCollaborators ), - useBroadcastSaveEvent: jest.fn( () => mockBroadcastSaveEvent ), + useLastPostSave: jest.fn( () => mockLastPostSave ), } ) ), } ) ); @@ -100,9 +98,6 @@ function makeMe( overrides: Record< string, unknown > = {} ) { function buildMockSelect() { return () => ( { - isSavingPost: () => mockEditorState.isSaving, - isAutosavingPost: () => mockEditorState.isAutosaving, - didPostSaveRequestSucceed: () => mockEditorState.didSaveSucceed, getCurrentPostAttribute: ( attr: string ) => attr === 'status' ? mockEditorState.postStatus : undefined, isCollaborationEnabledForCurrentPost: () => @@ -112,15 +107,12 @@ function buildMockSelect() { beforeEach( () => { mockActiveCollaborators = []; + mockLastPostSave = null; mockEditorState = { - isSaving: false, - isAutosaving: false, - didSaveSucceed: true, postStatus: 'draft', isCollaborationEnabled: true, }; mockCreateNotice.mockClear(); - mockBroadcastSaveEvent.mockClear(); ( applyFilters as jest.Mock ).mockImplementation( ( _hook: string, defaultValue: unknown ) => defaultValue ); @@ -339,14 +331,11 @@ describe( 'useCollaboratorNotifications', () => { useCollaboratorNotifications( 123, 'post' ) ); - // Alice saves the post (draft status) - mockActiveCollaborators = [ - makeMe(), - { - ...alice, - lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, - }, - ]; + // State map reports Alice saved + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: alice.clientId, + }; rerender(); expect( mockCreateNotice ).toHaveBeenCalledWith( @@ -361,22 +350,20 @@ describe( 'useCollaboratorNotifications', () => { } ); it( 'fires a post updated notification with "Post updated" for published status', () => { + mockEditorState = { + ...mockEditorState, + postStatus: 'publish', + }; const alice = makeCollaborator(); mockActiveCollaborators = [ makeMe(), alice ]; const { rerender } = renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); - mockActiveCollaborators = [ - makeMe(), - { - ...alice, - lastSaveEvent: { - savedAt: Date.now(), - status: 'publish', - }, - }, - ]; + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: alice.clientId, + }; rerender(); expect( mockCreateNotice ).toHaveBeenCalledWith( @@ -388,76 +375,89 @@ describe( 'useCollaboratorNotifications', () => { ); } ); - it( 'does not fire a post updated notification on the initial mount even with a saveEvent', () => { - mockActiveCollaborators = [ - makeMe(), - { - ...makeCollaborator(), - lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, - }, - ]; + it( 'does not fire a notification when the current user saves', () => { + const me = makeMe(); + mockActiveCollaborators = [ me, makeCollaborator() ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); - renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); + // State map reports "me" saved + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: me.clientId, + }; + rerender(); expect( mockCreateNotice ).not.toHaveBeenCalled(); } ); - it( 'does not fire a post updated notification for a newly appeared collaborator with a historical save event', () => { - const me = makeMe(); - mockActiveCollaborators = [ me ]; + it( 'does not fire duplicate notifications for the same savedAt timestamp', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const savedAt = Date.now(); + + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // First save event + mockLastPostSave = { + savedAt, + savedByClientId: alice.clientId, + }; + rerender(); + mockCreateNotice.mockClear(); + + // Rerender with same savedAt — should not notify again + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire a notification when a peer reconnects without a new save', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; const { rerender } = renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); - // Alice appears for the first time carrying a save event from - // before our session — should not trigger a notification. + // Alice disconnects and reconnects — useLastPostSave filters + // pre-existing state map values via its baseline check, so + // lastPostSave stays null. mockActiveCollaborators = [ - me, - { - ...makeCollaborator( { - collaboratorInfo: { - id: 100, - name: 'Alice', - slug: 'alice', - avatar_urls: {}, - browserType: 'Chrome', - enteredAt: BASE_ENTERED_AT + 10000, - }, - } ), - lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, - }, + makeMe(), + { ...alice, isConnected: false }, ]; rerender(); + mockCreateNotice.mockClear(); - // The join notification fires, but no save notification. - expect( mockCreateNotice ).toHaveBeenCalledTimes( 1 ); - expect( mockCreateNotice ).toHaveBeenCalledWith( - 'info', - 'Alice has joined the post.', - expect.objectContaining( { - id: 'collab-user-entered-100', - } ) + mockActiveCollaborators = [ + makeMe(), + { ...alice, clientId: 50, isConnected: true }, + ]; + rerender(); + + // Only the leave/join notices should have fired, no save notice. + const saveNotice = mockCreateNotice.mock.calls.find( + ( [ , msg ] ) => ( msg as string ).includes( 'saved' ) ); + expect( saveNotice ).toBeUndefined(); } ); - it( 'does not fire duplicate notifications for the same savedAt timestamp', () => { - const savedAt = Date.now(); - const alice = { - ...makeCollaborator(), - lastSaveEvent: { savedAt, status: 'draft' }, - }; - - mockActiveCollaborators = [ makeMe(), alice ]; + it( 'does not fire a notification when the saver is not in the collaborator list', () => { + mockActiveCollaborators = [ makeMe(), makeCollaborator() ]; const { rerender } = renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); - // Simulate a re-render with the same saveEvent - mockActiveCollaborators = [ makeMe(), { ...alice } ]; + // State map reports a save by unknown clientId + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: 12345, + }; rerender(); - // The initial mount should record it, not notify. - // A subsequent rerender with the same savedAt should not notify either. expect( mockCreateNotice ).not.toHaveBeenCalled(); } ); } ); @@ -525,96 +525,13 @@ describe( 'useCollaboratorNotifications', () => { useCollaboratorNotifications( 123, 'post' ) ); - mockActiveCollaborators = [ - makeMe(), - { - ...alice, - lastSaveEvent: { savedAt: Date.now(), status: 'draft' }, - }, - ]; - rerender(); - - expect( mockCreateNotice ).not.toHaveBeenCalled(); - } ); - } ); - - describe( 'save event broadcasting', () => { - it( 'broadcasts save event when save transitions from in-progress to complete', () => { - mockActiveCollaborators = []; - const { rerender } = renderHook( () => - useCollaboratorNotifications( 123, 'post' ) - ); - - // Save starts - mockEditorState = { - ...mockEditorState, - isSaving: true, - isAutosaving: false, - }; - rerender(); - - // Save completes - mockEditorState = { - ...mockEditorState, - isSaving: false, - isAutosaving: false, - didSaveSucceed: true, - postStatus: 'draft', - }; - rerender(); - - expect( mockBroadcastSaveEvent ).toHaveBeenCalledWith( 'draft' ); - } ); - - it( 'does not broadcast save event for autosaves', () => { - mockActiveCollaborators = []; - const { rerender } = renderHook( () => - useCollaboratorNotifications( 123, 'post' ) - ); - - // Autosave starts - mockEditorState = { - ...mockEditorState, - isSaving: true, - isAutosaving: true, - }; - rerender(); - - // Autosave completes - mockEditorState = { - ...mockEditorState, - isSaving: false, - isAutosaving: false, - }; - rerender(); - - expect( mockBroadcastSaveEvent ).not.toHaveBeenCalled(); - } ); - - it( 'does not broadcast when collaboration is not enabled', () => { - mockEditorState = { - ...mockEditorState, - isCollaborationEnabled: false, - }; - mockActiveCollaborators = []; - const { rerender } = renderHook( () => - useCollaboratorNotifications( 123, 'post' ) - ); - - mockEditorState = { - ...mockEditorState, - isSaving: true, - isCollaborationEnabled: false, - }; - rerender(); - - mockEditorState = { - ...mockEditorState, - isSaving: false, + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: alice.clientId, }; rerender(); - expect( mockBroadcastSaveEvent ).not.toHaveBeenCalled(); + expect( mockCreateNotice ).not.toHaveBeenCalled(); } ); } ); } ); diff --git a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts index f36584357c2d9f..da5ba8e46344c3 100644 --- a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts +++ b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts @@ -18,7 +18,7 @@ import { import { unlock } from '../../lock-unlock'; import { store as editorStore } from '../../store'; -const { useActiveCollaborators, useBroadcastSaveEvent } = unlock( privateApis ); +const { useActiveCollaborators, useLastPostSave } = unlock( privateApis ); /** * Notice IDs for each notification type. Using stable IDs prevents duplicate @@ -77,20 +77,11 @@ export function useCollaboratorNotifications( postType ) as PostEditorAwarenessState[]; - const broadcastSaveEvent = useBroadcastSaveEvent( postId, postType ); + const lastPostSave = useLastPostSave( postId, postType ); - const { - isSaving, - isAutosaving, - didSaveSucceed, - postStatus, - isCollaborationEnabled, - } = useSelect( ( select ) => { + const { postStatus, isCollaborationEnabled } = useSelect( ( select ) => { const editorSel = select( editorStore ); return { - isSaving: editorSel.isSavingPost(), - isAutosaving: editorSel.isAutosavingPost(), - didSaveSucceed: editorSel.didPostSaveRequestSucceed(), postStatus: editorSel.getCurrentPostAttribute( 'status' ) as | string | undefined, @@ -111,33 +102,10 @@ export function useCollaboratorNotifications( ); const prevCollaborators = usePrevious( activeCollaborators ); - const prevIsSaving = usePrevious( isSaving ); - const prevIsAutosaving = usePrevious( isAutosaving ); - - // Broadcast our own saves to collaborators via awareness. - useEffect( () => { - if ( - prevIsSaving && - ! isSaving && - ! prevIsAutosaving && - didSaveSucceed && - isCollaborationEnabled && - postStatus - ) { - broadcastSaveEvent( postStatus ); - } - }, [ - isSaving, - prevIsSaving, - prevIsAutosaving, - didSaveSucceed, - postStatus, - isCollaborationEnabled, - broadcastSaveEvent, - ] ); + const prevPostSave = usePrevious( lastPostSave ); /* - * Detect collaborator joins, leaves, and remote saves. + * Detect collaborator joins and leaves. */ useEffect( () => { if ( ! isCollaborationEnabled ) { @@ -153,9 +121,6 @@ export function useCollaboratorNotifications( return; } - /* - * Convenience wrapper to keep notice calls concise. - */ function notify( noticeId: string, message: string ) { void createNotice( 'info', message, { id: noticeId, @@ -235,44 +200,57 @@ export function useCollaboratorNotifications( ); } } + }, [ + activeCollaborators, + prevCollaborators, + isCollaborationEnabled, + notificationsConfig, + createNotice, + ] ); - // Detect remote save events from other collaborators. - if ( notificationsConfig.postUpdated ) { - for ( const [ clientId, collaborator ] of newMap ) { - if ( collaborator.isMe || ! collaborator.lastSaveEvent ) { - continue; - } - - const prevCollab = prevMap.get( clientId ); + /* + * Detect remote save events via the CRDT state map. The savedByClientId + * is a Y.Doc client ID which maps to a collaborator via clientId. + */ + useEffect( () => { + if ( + ! isCollaborationEnabled || + ! notificationsConfig.postUpdated || + ! lastPostSave || + ! postStatus + ) { + return; + } - /* - * Only notify for save events from collaborators we already - * knew about. A newly appeared collaborator may carry a - * historical save event that predates our session. - */ - if ( ! prevCollab ) { - continue; - } + if ( prevPostSave && lastPostSave.savedAt === prevPostSave.savedAt ) { + return; + } - const prevSavedAt = prevCollab.lastSaveEvent?.savedAt; - const { savedAt, status } = collaborator.lastSaveEvent; + const saver = activeCollaborators.find( + ( c ) => c.clientId === lastPostSave.savedByClientId && ! c.isMe + ); - if ( savedAt > ( prevSavedAt ?? 0 ) ) { - notify( - `${ NOTIFICATION_TYPE.POST_UPDATED }-${ collaborator.collaboratorInfo.id }`, - getPostUpdatedMessage( - collaborator.collaboratorInfo.name, - status - ) - ); - } - } + if ( ! saver ) { + return; } + + const message = getPostUpdatedMessage( + saver.collaboratorInfo.name, + postStatus + ); + + void createNotice( 'info', message, { + id: `${ NOTIFICATION_TYPE.POST_UPDATED }-${ saver.collaboratorInfo.id }`, + type: 'snackbar', + isDismissible: false, + } ); }, [ + lastPostSave, + prevPostSave, activeCollaborators, - prevCollaborators, isCollaborationEnabled, notificationsConfig, + postStatus, createNotice, ] ); } From 4b13b1f6c54b82e9be2548076a0e5c1640c3de4b Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Fri, 27 Feb 2026 23:47:16 +0530 Subject: [PATCH 05/14] RTC: Show correct notification when collaborator publishes The notification always showed "Draft saved by X" because post status was read from the local Redux store, which lags behind the Y.Doc. Fix by reading status from the Y.Doc record map (updated in the same transaction as the save marker) and distinguishing three cases: "Post published", "Post updated", and "Draft saved". --- .../hooks/use-post-editor-awareness-state.ts | 7 +++- .../use-collaborator-notifications.ts | 37 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index c90d61dfe1286e..c62aa79815871e 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -161,6 +161,7 @@ export function useIsDisconnected( export interface PostSaveEvent { savedAt: number; savedByClientId: number; + postStatus: string | undefined; } /** @@ -194,6 +195,7 @@ export function useLastPostSave( } const stateMap = awareness.doc.getMap( 'state' ); + const recordMap = awareness.doc.getMap( 'document' ); // Only notify for saves that occur after the observer is // set up. This prevents false notifications when the Y.Doc @@ -210,7 +212,10 @@ export function useLastPostSave( typeof savedByClientId === 'number' && savedAt > setupTime ) { - setLastSave( { savedAt, savedByClientId } ); + const postStatus = recordMap.get( 'status' ) as + | string + | undefined; + setLastSave( { savedAt, savedByClientId, postStatus } ); } } }; diff --git a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts index da5ba8e46344c3..0a6cbedb6e48ad 100644 --- a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts +++ b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts @@ -42,15 +42,25 @@ const DEFAULT_NOTIFICATIONS_CONFIG: CollaborationNotificationsConfig = { postUpdated: true, }; +const PUBLISHED_STATUSES = [ 'publish', 'private', 'future' ]; + /** - * Returns the snackbar message for a post updated notification based on post status. + * Returns the snackbar message for a post updated notification. * - * @param name Display name of the collaborator who saved. - * @param status WordPress post status at the time of save. + * @param name Display name of the collaborator who saved. + * @param status WordPress post status at the time of save. + * @param isFirstPublish Whether this save transitioned the post to published. */ -function getPostUpdatedMessage( name: string, status: string ): string { - const isPublished = [ 'publish', 'private', 'future' ].includes( status ); - if ( isPublished ) { +function getPostUpdatedMessage( + name: string, + status: string, + isFirstPublish: boolean +): string { + if ( isFirstPublish ) { + /* translators: %s: collaborator display name */ + return sprintf( __( 'Post published by %s.' ), name ); + } + if ( PUBLISHED_STATUSES.includes( status ) ) { /* translators: %s: collaborator display name */ return sprintf( __( 'Post updated by %s.' ), name ); } @@ -234,9 +244,22 @@ export function useCollaboratorNotifications( return; } + // Prefer the remote status from Y.Doc (accurate at save time) over + // the local Redux value, which may not have synced yet. + const effectiveStatus = + lastPostSave.postStatus ?? postStatus ?? 'draft'; + + // prevPostSave is null on the first save this session, so fall back + // to the Redux status (still pre-save when the notification fires). + const prevStatus = prevPostSave?.postStatus ?? postStatus; + const isFirstPublish = + ! ( prevStatus && PUBLISHED_STATUSES.includes( prevStatus ) ) && + PUBLISHED_STATUSES.includes( effectiveStatus ); + const message = getPostUpdatedMessage( saver.collaboratorInfo.name, - postStatus + effectiveStatus, + isFirstPublish ); void createNotice( 'info', message, { From 4e78f67b9bcd5f741ec08d317229280e5ab1a818 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Sun, 1 Mar 2026 15:39:09 +0530 Subject: [PATCH 06/14] RTC: Increase awareness timeout to avoid false disconnects The 30 s awareness timeout matched the background-tab polling interval exactly, causing awareness entries to expire between polls and triggering false "X has left" notifications that resolved on the next poll cycle. Increase to 70 s to comfortably exceed the polling interval. The value accounts for Chrome's intensive throttling which can delay background tab timers to ~60 s --- .../wordpress-7.0/class-wp-http-polling-sync-server.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php index 00aa76793c0d3b..0c8ffb289f4a7f 100644 --- a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php +++ b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php @@ -26,10 +26,16 @@ class WP_HTTP_Polling_Sync_Server { * Awareness timeout in seconds. Clients that haven't updated * their awareness state within this time are considered disconnected. * + * Background tabs poll every 30 s, but Chrome's intensive throttling + * (after ~5 min in background) can delay polls to ~60 s. The timeout + * must exceed that to avoid false disconnects. + * + * @see https://developer.chrome.com/blog/timer-throttling-in-chrome-88 + * * @since 7.0.0 * @var int */ - const AWARENESS_TIMEOUT = 30; + const AWARENESS_TIMEOUT = 70; /** * Threshold used to signal clients to send a compaction update. From 94a9779e93c835eb1ab20fa9317fef3e1a285c22 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 00:28:01 +0530 Subject: [PATCH 07/14] RTC: Remove filter for notification config --- .../test/use-collaborator-notifications.ts | 83 ------------------- .../use-collaborator-notifications.ts | 31 ++----- 2 files changed, 5 insertions(+), 109 deletions(-) diff --git a/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts index 0bd085eb83cd0e..baf03e7be85051 100644 --- a/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts +++ b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts @@ -7,7 +7,6 @@ import { renderHook } from '@testing-library/react'; * WordPress dependencies */ import { useDispatch, useSelect } from '@wordpress/data'; -import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies @@ -30,12 +29,6 @@ jest.mock( '@wordpress/data', () => ( { useDispatch: jest.fn(), } ) ); -jest.mock( '@wordpress/hooks', () => ( { - applyFilters: jest.fn( - ( _hook: string, defaultValue: unknown ) => defaultValue - ), -} ) ); - jest.mock( '@wordpress/notices', () => ( { store: 'core/notices', } ) ); @@ -113,9 +106,6 @@ beforeEach( () => { isCollaborationEnabled: true, }; mockCreateNotice.mockClear(); - ( applyFilters as jest.Mock ).mockImplementation( - ( _hook: string, defaultValue: unknown ) => defaultValue - ); ( useSelect as jest.Mock ).mockImplementation( ( selector: Function ) => selector( buildMockSelect() ) ); @@ -461,77 +451,4 @@ describe( 'useCollaboratorNotifications', () => { expect( mockCreateNotice ).not.toHaveBeenCalled(); } ); } ); - - describe( 'WordPress filter: editor.collaborationNotifications', () => { - it( 'suppresses join notifications when userEntered is false', () => { - ( applyFilters as jest.Mock ).mockReturnValue( { - userEntered: false, - userExited: true, - postUpdated: true, - } ); - - mockActiveCollaborators = [ makeMe() ]; - const { rerender } = renderHook( () => - useCollaboratorNotifications( 123, 'post' ) - ); - - mockActiveCollaborators = [ - makeMe(), - makeCollaborator( { - collaboratorInfo: { - id: 100, - name: 'Alice', - slug: 'alice', - avatar_urls: {}, - browserType: 'Chrome', - enteredAt: BASE_ENTERED_AT + 10000, - }, - } ), - ]; - rerender(); - - expect( mockCreateNotice ).not.toHaveBeenCalled(); - } ); - - it( 'suppresses leave notifications when userExited is false', () => { - ( applyFilters as jest.Mock ).mockReturnValue( { - userEntered: true, - userExited: false, - postUpdated: true, - } ); - - const alice = makeCollaborator(); - mockActiveCollaborators = [ makeMe(), alice ]; - const { rerender } = renderHook( () => - useCollaboratorNotifications( 123, 'post' ) - ); - - mockActiveCollaborators = [ makeMe() ]; - rerender(); - - expect( mockCreateNotice ).not.toHaveBeenCalled(); - } ); - - it( 'suppresses post updated notifications when postUpdated is false', () => { - ( applyFilters as jest.Mock ).mockReturnValue( { - userEntered: true, - userExited: true, - postUpdated: false, - } ); - - const alice = makeCollaborator(); - mockActiveCollaborators = [ makeMe(), alice ]; - const { rerender } = renderHook( () => - useCollaboratorNotifications( 123, 'post' ) - ); - - mockLastPostSave = { - savedAt: Date.now(), - savedByClientId: alice.clientId, - }; - rerender(); - - expect( mockCreateNotice ).not.toHaveBeenCalled(); - } ); - } ); } ); diff --git a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts index 0a6cbedb6e48ad..6bfdf9128a6e9b 100644 --- a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts +++ b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts @@ -3,8 +3,7 @@ */ import { usePrevious } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect, useMemo } from '@wordpress/element'; -import { applyFilters } from '@wordpress/hooks'; +import { useEffect } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { @@ -30,13 +29,7 @@ const NOTIFICATION_TYPE = { USER_EXITED: 'collab-user-exited', } as const; -interface CollaborationNotificationsConfig { - userEntered: boolean; - userExited: boolean; - postUpdated: boolean; -} - -const DEFAULT_NOTIFICATIONS_CONFIG: CollaborationNotificationsConfig = { +const NOTIFICATIONS_CONFIG = { userEntered: true, userExited: true, postUpdated: true, @@ -72,9 +65,6 @@ function getPostUpdatedMessage( * Hook that watches for collaborator join/leave events and remote save events, * dispatching snackbar notices accordingly. * - * Notification types can be enabled/disabled via the - * `editor.collaborationNotifications` WordPress filter. - * * @param postId The ID of the post being edited. * @param postType The post type of the post being edited. */ @@ -102,15 +92,6 @@ export function useCollaboratorNotifications( const { createNotice } = useDispatch( noticesStore ); - const notificationsConfig = useMemo( - () => - applyFilters( - 'editor.collaborationNotifications', - DEFAULT_NOTIFICATIONS_CONFIG - ) as CollaborationNotificationsConfig, - [] - ); - const prevCollaborators = usePrevious( activeCollaborators ); const prevPostSave = usePrevious( lastPostSave ); @@ -149,7 +130,7 @@ export function useCollaboratorNotifications( /* * Detect joins: new clientIds that weren't in the previous state. */ - if ( notificationsConfig.userEntered ) { + if ( NOTIFICATIONS_CONFIG.userEntered ) { const me = activeCollaborators.find( ( c ) => c.isMe ); for ( const [ clientId, collaborator ] of newMap ) { @@ -189,7 +170,7 @@ export function useCollaboratorNotifications( * Already-disconnected collaborators that are later removed from the * list (after the 5 s delay) are silently ignored. */ - if ( notificationsConfig.userExited ) { + if ( NOTIFICATIONS_CONFIG.userExited ) { for ( const [ clientId, prevCollab ] of prevMap ) { if ( prevCollab.isMe || ! prevCollab.isConnected ) { continue; @@ -214,7 +195,6 @@ export function useCollaboratorNotifications( activeCollaborators, prevCollaborators, isCollaborationEnabled, - notificationsConfig, createNotice, ] ); @@ -225,7 +205,7 @@ export function useCollaboratorNotifications( useEffect( () => { if ( ! isCollaborationEnabled || - ! notificationsConfig.postUpdated || + ! NOTIFICATIONS_CONFIG.postUpdated || ! lastPostSave || ! postStatus ) { @@ -272,7 +252,6 @@ export function useCollaboratorNotifications( prevPostSave, activeCollaborators, isCollaborationEnabled, - notificationsConfig, postStatus, createNotice, ] ); From 15c3217acfe96feb658bc16e671afe9bb059b5dc Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 00:30:49 +0530 Subject: [PATCH 08/14] RTC: Move PostSaveEvent interface to shared awareness types --- packages/core-data/src/awareness/types.ts | 6 ++++++ .../core-data/src/hooks/use-post-editor-awareness-state.ts | 7 +------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/core-data/src/awareness/types.ts b/packages/core-data/src/awareness/types.ts index eee5a7036da3f2..847d4b3eaa2709 100644 --- a/packages/core-data/src/awareness/types.ts +++ b/packages/core-data/src/awareness/types.ts @@ -94,3 +94,9 @@ export type EqualityFieldCheck< State, FieldName extends keyof State > = ( value1?: State[ FieldName ], value2?: State[ FieldName ] ) => boolean; + +export interface PostSaveEvent { + savedAt: number; + savedByClientId: number; + postStatus: string | undefined; +} diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index c62aa79815871e..c8a22e15cc9f55 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -10,6 +10,7 @@ import type { Y } from '@wordpress/sync'; import { getSyncManager } from '../sync'; import type { PostEditorAwarenessState as ActiveCollaborator, + PostSaveEvent, YDocDebugData, } from '../awareness/types'; import type { SelectionState } from '../types'; @@ -158,12 +159,6 @@ export function useIsDisconnected( .isCurrentCollaboratorDisconnected; } -export interface PostSaveEvent { - savedAt: number; - savedByClientId: number; - postStatus: string | undefined; -} - /** * Hook that subscribes to the CRDT state map and returns the most recent * save event (timestamp + client ID). The state map is updated by From d18ed4c199a3032f7a3a5dada1f5f6e37b8c1306 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 13:13:38 +0530 Subject: [PATCH 09/14] RTC: Add backport PR link --- backport-changelog/7.0/11125.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backport-changelog/7.0/11125.md diff --git a/backport-changelog/7.0/11125.md b/backport-changelog/7.0/11125.md new file mode 100644 index 00000000000000..169f805fe0f518 --- /dev/null +++ b/backport-changelog/7.0/11125.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/11125 + +* https://github.com/WordPress/gutenberg/pull/76065 From d27e9f06b78fee2673467422855b5e43bf40dc99 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 15:21:36 +0530 Subject: [PATCH 10/14] RTC: Align notification type keys with their values --- .../use-collaborator-notifications.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts index 6bfdf9128a6e9b..6a3820f505934c 100644 --- a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts +++ b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts @@ -24,9 +24,9 @@ const { useActiveCollaborators, useLastPostSave } = unlock( privateApis ); * notices if the same event is processed more than once. */ const NOTIFICATION_TYPE = { - POST_UPDATED: 'collab-post-updated', - USER_ENTERED: 'collab-user-entered', - USER_EXITED: 'collab-user-exited', + COLLAB_POST_UPDATED: 'collab-post-updated', + COLLAB_USER_ENTERED: 'collab-user-entered', + COLLAB_USER_EXITED: 'collab-user-exited', } as const; const NOTIFICATIONS_CONFIG = { @@ -152,7 +152,7 @@ export function useCollaboratorNotifications( } notify( - `${ NOTIFICATION_TYPE.USER_ENTERED }-${ collaborator.collaboratorInfo.id }`, + `${ NOTIFICATION_TYPE.COLLAB_USER_ENTERED }-${ collaborator.collaboratorInfo.id }`, sprintf( /* translators: %s: collaborator display name */ __( '%s has joined the post.' ), @@ -182,7 +182,7 @@ export function useCollaboratorNotifications( } notify( - `${ NOTIFICATION_TYPE.USER_EXITED }-${ prevCollab.collaboratorInfo.id }`, + `${ NOTIFICATION_TYPE.COLLAB_USER_EXITED }-${ prevCollab.collaboratorInfo.id }`, sprintf( /* translators: %s: collaborator display name */ __( '%s has left the post.' ), @@ -243,7 +243,7 @@ export function useCollaboratorNotifications( ); void createNotice( 'info', message, { - id: `${ NOTIFICATION_TYPE.POST_UPDATED }-${ saver.collaboratorInfo.id }`, + id: `${ NOTIFICATION_TYPE.COLLAB_POST_UPDATED }-${ saver.collaboratorInfo.id }`, type: 'snackbar', isDismissible: false, } ); From 5d0206e8260a25b32d773f6a87a7eacb3eb7e351 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 17:32:08 +0530 Subject: [PATCH 11/14] RTC: Call setUp in useLastPostSave to ensure awareness is initialized --- packages/core-data/src/hooks/use-post-editor-awareness-state.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index c8a22e15cc9f55..212a6b12c2e1c6 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -189,6 +189,8 @@ export function useLastPostSave( return; } + awareness.setUp(); + const stateMap = awareness.doc.getMap( 'state' ); const recordMap = awareness.doc.getMap( 'document' ); From c8c9cd5fce28f8e25c74f4a4278204f06da60e0f Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 21:40:34 +0530 Subject: [PATCH 12/14] RTC: Remove setTimeout yield from saveEntityRecord --- packages/core-data/src/actions.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index e38f5f160fd808..a4e9e4d3505f78 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -771,15 +771,6 @@ export const saveEntityRecord = } else { let edits = record; if ( entityConfig.__unstablePrePersist ) { - // When sync (collaboration) is enabled, Y.Doc updates - // are deferred via setTimeout(0). Yield to the event - // loop so pending updates are flushed before - // __unstablePrePersist serializes the CRDT document. - if ( entityConfig.syncConfig ) { - await new Promise( ( resolve ) => - setTimeout( resolve, 0 ) - ); - } edits = { ...edits, ...( await entityConfig.__unstablePrePersist( From efdcc974149722e860b292dc2dac8d2f0a75a3c0 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 22:10:28 +0530 Subject: [PATCH 13/14] RTC: Reduce background tab poll interval to avoid false disconnects Reverts AWARENESS_TIMEOUT to 30 s and reduces the background tab polling interval to 25 s so polls arrive before the server-side timeout expires. --- .../wordpress-7.0/class-wp-http-polling-sync-server.php | 8 +------- .../sync/src/providers/http-polling/polling-manager.ts | 4 +++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php index 0c8ffb289f4a7f..00aa76793c0d3b 100644 --- a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php +++ b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php @@ -26,16 +26,10 @@ class WP_HTTP_Polling_Sync_Server { * Awareness timeout in seconds. Clients that haven't updated * their awareness state within this time are considered disconnected. * - * Background tabs poll every 30 s, but Chrome's intensive throttling - * (after ~5 min in background) can delay polls to ~60 s. The timeout - * must exceed that to avoid false disconnects. - * - * @see https://developer.chrome.com/blog/timer-throttling-in-chrome-88 - * * @since 7.0.0 * @var int */ - const AWARENESS_TIMEOUT = 70; + const AWARENESS_TIMEOUT = 30; /** * Threshold used to signal clients to send a compaction update. diff --git a/packages/sync/src/providers/http-polling/polling-manager.ts b/packages/sync/src/providers/http-polling/polling-manager.ts index 070d98198fdf8a..ea052ce4a3e3fe 100644 --- a/packages/sync/src/providers/http-polling/polling-manager.ts +++ b/packages/sync/src/providers/http-polling/polling-manager.ts @@ -30,7 +30,9 @@ import { const POLLING_INTERVAL_IN_MS = 1000; // 1 second or 1000 milliseconds const POLLING_INTERVAL_WITH_COLLABORATORS_IN_MS = 250; // 250 milliseconds -const POLLING_INTERVAL_BACKGROUND_TAB_IN_MS = 30 * 1000; // 30 seconds +// Must be less than the server-side AWARENESS_TIMEOUT (30 s) to avoid +// false disconnects when the tab is in the background. +const POLLING_INTERVAL_BACKGROUND_TAB_IN_MS = 25 * 1000; // 25 seconds const MAX_ERROR_BACKOFF_IN_MS = 30 * 1000; // 30 seconds const POLLING_MANAGER_ORIGIN = 'polling-manager'; From 9daebb68bac8682b84ad9db09b4885e7f398a1e7 Mon Sep 17 00:00:00 2001 From: Shekhar Wagh Date: Tue, 3 Mar 2026 22:13:21 +0530 Subject: [PATCH 14/14] RTC: Remove backport PR link The AWARENESS_TIMEOUT change has been reverted so a backport PR is no longer needed. --- backport-changelog/7.0/11125.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 backport-changelog/7.0/11125.md diff --git a/backport-changelog/7.0/11125.md b/backport-changelog/7.0/11125.md deleted file mode 100644 index 169f805fe0f518..00000000000000 --- a/backport-changelog/7.0/11125.md +++ /dev/null @@ -1,3 +0,0 @@ -https://github.com/WordPress/wordpress-develop/pull/11125 - -* https://github.com/WordPress/gutenberg/pull/76065