Skip to content

Commit 588d4d0

Browse files
alecgeatchesalecgeatcheschriszarate
authored
Real-time collaboration: Remove ghost awareness state explicitly when refreshing (WordPress#75883)
* Explicitly send a null awareness state on pagehide to remove stale cursors * Add postSyncUpdateNonBlocking instead of sendDisconnect, construct sync payload in caller * Add backport changelog file Co-authored-by: alecgeatches <alecgeatches@git.wordpress.org> Co-authored-by: chriszarate <czarate@git.wordpress.org>
1 parent 83a8f44 commit 588d4d0

File tree

4 files changed

+73
-4
lines changed

4 files changed

+73
-4
lines changed

backport-changelog/7.0/11049.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
https://github.com/WordPress/wordpress-develop/pull/11049
2+
3+
* https://github.com/WordPress/gutenberg/pull/75883

lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ public function register_routes(): void {
124124
),
125125
'awareness' => array(
126126
'required' => true,
127-
'type' => 'object',
127+
'type' => array( 'object', 'null' ),
128128
),
129129
'client_id' => array(
130130
'minimum' => 1,

packages/sync/src/providers/http-polling/polling-manager.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
createSyncUpdate,
2626
createUpdateQueue,
2727
postSyncUpdate,
28+
postSyncUpdateNonBlocking,
2829
} from './utils';
2930

3031
const POLLING_INTERVAL_IN_MS = 1000; // 1 second or 1000 milliseconds
@@ -240,6 +241,25 @@ function processDocUpdate(
240241

241242
let isPolling = false;
242243
let pollInterval = POLLING_INTERVAL_IN_MS;
244+
let pageHideListenerRegistered = false;
245+
246+
/**
247+
* Send a disconnect signal for all registered rooms when the page is
248+
* being unloaded. Uses `sendBeacon` so the request survives navigation.
249+
*/
250+
function handlePageHide(): void {
251+
const rooms = Array.from( roomStates.entries() ).map(
252+
( [ room, state ] ) => ( {
253+
after: 0,
254+
awareness: null,
255+
client_id: state.clientId,
256+
room,
257+
updates: [],
258+
} )
259+
);
260+
261+
postSyncUpdateNonBlocking( { rooms } );
262+
}
243263

244264
function poll(): void {
245265
isPolling = true;
@@ -394,7 +414,6 @@ function registerRoom( {
394414
function unregister(): void {
395415
doc.off( 'update', onDocUpdate );
396416
awareness.off( 'change', onAwarenessUpdate );
397-
// TODO: poll will null awareness state to trigger removal
398417
updateQueue.clear();
399418
}
400419

@@ -421,14 +440,40 @@ function registerRoom( {
421440
awareness.on( 'change', onAwarenessUpdate );
422441
roomStates.set( room, roomState );
423442

443+
if ( ! pageHideListenerRegistered ) {
444+
window.addEventListener( 'pagehide', handlePageHide );
445+
pageHideListenerRegistered = true;
446+
}
447+
424448
if ( ! isPolling ) {
425449
poll();
426450
}
427451
}
428452

429453
function unregisterRoom( room: string ): void {
430-
roomStates.get( room )?.unregister();
431-
roomStates.delete( room );
454+
const state = roomStates.get( room );
455+
if ( state ) {
456+
// Send a disconnect signal so the server removes this client's
457+
// awareness entry immediately instead of waiting for the timeout.
458+
const rooms = [
459+
{
460+
after: 0,
461+
awareness: null,
462+
client_id: state.clientId,
463+
room,
464+
updates: [],
465+
},
466+
];
467+
468+
postSyncUpdateNonBlocking( { rooms } );
469+
state.unregister();
470+
roomStates.delete( room );
471+
}
472+
473+
if ( roomStates.size === 0 && pageHideListenerRegistered ) {
474+
window.removeEventListener( 'pagehide', handlePageHide );
475+
pageHideListenerRegistered = false;
476+
}
432477
}
433478

434479
export const pollingManager: PollingManager = {

packages/sync/src/providers/http-polling/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,24 @@ export async function postSyncUpdate(
126126

127127
return await response.json();
128128
}
129+
130+
/**
131+
* Fire-and-forget variant of postSyncUpdate. Uses `keepalive` so the
132+
* request survives page unload, and errors are silently ignored.
133+
*
134+
* @param payload The sync payload to send.
135+
*/
136+
export function postSyncUpdateNonBlocking( payload: SyncPayload ): void {
137+
if ( payload.rooms.length === 0 ) {
138+
return;
139+
}
140+
141+
apiFetch( {
142+
body: JSON.stringify( payload ),
143+
headers: { 'Content-Type': 'application/json' },
144+
keepalive: true,
145+
method: 'POST',
146+
parse: false,
147+
path: SYNC_API_PATH,
148+
} ).catch( () => {} );
149+
}

0 commit comments

Comments
 (0)