Skip to content

Real-time collaboration: Remove ghost awareness state explicitly when refreshing#75883

Merged
alecgeatches merged 3 commits intoWordPress:trunkfrom
Automattic:fix/ghost-cursors
Feb 25, 2026
Merged

Real-time collaboration: Remove ghost awareness state explicitly when refreshing#75883
alecgeatches merged 3 commits intoWordPress:trunkfrom
Automattic:fix/ghost-cursors

Conversation

@alecgeatches
Copy link
Contributor

@alecgeatches alecgeatches commented Feb 24, 2026

What?

This PR explicitly disconnects users from awareness when the page is refreshed or otherwise navigated using the browser pagehide event.

Why?

When refreshing the page in real-time collaboration, the previous session's cursor stays around for a while. This can lead to confusion from ghost collaborators from the same user and have a negative visual impact:

Screen.Recording.2026-02-24.at.12.17.29.PM.mov

Clicking into a post and refreshing quickly creates several ghost users and cursors in trunk

How?

This PR explicitly sends a disconnect signal (null awareness update) on pagehide. From MDN:

If you're specifically trying to detect page unload events, the pagehide event is the best option.

The documentation above notes that this behavior works differently on mobile, but only in the way that it will NOT be explicitly fired if a user force-closes a browser in the background. This is fine, since awareness state will automatically be cleared out after a 30-second interval anyway.


We also apiFetch() with the keepalive: true option, which according to documentation:

This enables a fetch() request to, for example, send analytics at the end of a session even if the user navigates away from or closes the page.

The naming is a bit confusing but keepalive: true means the disconnect request can finish independent of the current page, so it's non-blocking to a refresh or navigation.

Testing Instructions

  1. Check out this PR.
  2. Ensure real-time collaboration is enabled via: WordPress Admin -> Settings -> Writing, and check the "Enable real-time collaboration" checkbox. Click the "Save Changes" button.
  3. Open a post with two separate users in two tabs.
  4. As user A, rapidly click into post content and refresh a few times.
  5. You should observe that as user A you do not see ghost cursors persist from previous sessions. It's possible that if a disconnect signal takes a while you may see a prior cursor for up to 5 seconds, but much less than before.
  6. As user B, you should see ghost cursors disappear after 5 seconds.

Screenshots

Here is the same demo as above but performed within this PR:

refresh-cursors-fixed.mov

I've also included a second user to see what this looks like to a third-party observer watching the same post.

Above we can see that user A (the left-hand side) never sees their cursor reappear, as their awareness data is removed during the refresh and no longer present after loading. User B has a short grace period for ghost cursors (5 seconds) before duplicate cursors are removed. This could be further improved but is relatively minor.

@alecgeatches alecgeatches self-assigned this Feb 24, 2026
@alecgeatches alecgeatches added [Type] Bug An existing feature does not function as intended [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration labels Feb 24, 2026
@alecgeatches alecgeatches added Backport to WP 7.0 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta and removed Backport to WP 7.0 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta labels Feb 24, 2026
@chriszarate
Copy link
Contributor

This feels related to #75829. @alecgeatches What do you think about using visibilitychange instead of pagehide? MDN notes:

The best event to use to signal the end of a user's session is the visibilitychange event. In browsers that don't support visibilitychange the pagehide event is the next-best alternative.

Do we want to hide presence indicators when the user is not actively "in" the session?

@github-actions
Copy link

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: alecgeatches <alecgeatches@git.wordpress.org>
Co-authored-by: chriszarate <czarate@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@alecgeatches
Copy link
Contributor Author

alecgeatches commented Feb 24, 2026

@alecgeatches What do you think about using visibilitychange instead of pagehide? MDN notes:

The best event to use to signal the end of a user's session is the visibilitychange event. In browsers that don't support visibilitychange the pagehide event is the next-best alternative.

Do we want to hide presence indicators when the user is not actively "in" the session?

@chriszarate I think this might be a step too far. As quoted above, pagehide is a better signal for "the page is done":

If you're specifically trying to detect page unload events, the pagehide event is the best option.

visibilitychange triggers for a ton of stuff:

This event fires with a visibilityState of hidden when a user navigates to a new page, switches tabs, closes the tab, minimizes or closes the browser, or, on mobile, switches from the browser to a different app.

This might make sense in a mobile-only context where switching tabs is more similar to exiting the session, but on a desktop I wouldn't expect switching tabs and minimizing to be a place where a user's session data is removed. This is also a bit simpler - we can fire and forget and don't need to worry about reinitializing a user if the tab is resumed.

Comment on lines +146 to +154
const payload: SyncPayload = {
rooms: rooms.map( ( { room, clientId } ) => ( {
after: 0,
awareness: null,
client_id: clientId,
room,
updates: [],
} ) ),
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick, but I'd rather see the parameters of these functions be consistent and accept SyncPayload[]. This mapping can be done in the calling code.

Copy link
Contributor Author

@alecgeatches alecgeatches Feb 24, 2026

Choose a reason for hiding this comment

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

Good idea! Removed sendDisconnect() in 7b1131b and added a non-blocking version of postSyncUpdate to call since it's essentially the same thing.

@alecgeatches
Copy link
Contributor Author

I have created a backport PR for core here: WordPress/wordpress-develop#11049

@alecgeatches alecgeatches merged commit 588d4d0 into WordPress:trunk Feb 25, 2026
41 of 50 checks passed
@alecgeatches alecgeatches deleted the fix/ghost-cursors branch February 25, 2026 20:27
@github-actions github-actions bot added this to the Gutenberg 22.7 milestone Feb 25, 2026
@github-actions github-actions bot removed the Backport to WP 7.0 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta label Feb 25, 2026
gutenbergplugin pushed a commit that referenced this pull request Feb 25, 2026
… refreshing (#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>
@github-actions github-actions bot added the Backported to WP Core Pull request that has been successfully merged into WP Core label Feb 25, 2026
@github-actions
Copy link

I just cherry-picked this PR to the wp/7.0 branch to get it included in the next release: 17eeb51

pento pushed a commit to WordPress/wordpress-develop that referenced this pull request Feb 26, 2026
… refreshing.

Syncs PHP changes from WordPress/gutenberg#75883. Changes the real-time collaboration polling server endpoint to accept null for awareness values, which is used to explicitly disconnect users leaving the page and remove their awareness data more quickly.

CI run: #11049.

Fixes #64622.
Props alecgeatches.

git-svn-id: https://develop.svn.wordpress.org/trunk@61746 602fd350-edb4-49c9-b593-d223f7449a82
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Feb 26, 2026
… refreshing.

Syncs PHP changes from WordPress/gutenberg#75883. Changes the real-time collaboration polling server endpoint to accept null for awareness values, which is used to explicitly disconnect users leaving the page and remove their awareness data more quickly.

CI run: WordPress/wordpress-develop#11049.

Fixes #64622.
Props alecgeatches.
Built from https://develop.svn.wordpress.org/trunk@61746


git-svn-id: http://core.svn.wordpress.org/trunk@61052 1a063a9b-81f0-0310-95a4-ce76da25c4cd
pento pushed a commit to WordPress/wordpress-develop that referenced this pull request Feb 26, 2026
CI run: #11059.

See #64595.

---

I've included a log of the Gutenberg changes with the following command:

git log --reverse --format="- %s" 23b566c72e9c4a36219ef5d6e62890f05551f6cb..022d8dd3d461f91b15c1f0410649d3ebb027207f | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d' | pbcopy

- Pattern Editing: Fix nested patterns/sections (WordPress/gutenberg#75772)
- QuickEdit: rename status label and remove extra labels in popup (WordPress/gutenberg#75824)
- Fix block editing modes not recomputing when isolated editor value changes (WordPress/gutenberg#75821)
- Synced patterns: Fix block editing mode of synced pattern content when nested in an unsynced pattern (WordPress/gutenberg#75818)
- Block Support: Fix custom CSS not saved when style schema is not defined (WordPress/gutenberg#75797)
- Gallery: Fixes keyboard focus escaping the lightbox overlay when navigating a gallery with Tab/Shift+Tab. (WordPress/gutenberg#75852)
- Navigation Overlay Close: Set Close as default text, rather than using a placeholder (WordPress/gutenberg#75692)
- RTC: Fix entity save call / initial persistence. (WordPress/gutenberg#75841)
- Real-time collaboration: Improve collaboration within the same rich text (WordPress/gutenberg#75703)
- Client Side Media: Add device/browser capability detection (WordPress/gutenberg#75863)
- Navigation editing: simplify edit/view buttons (WordPress/gutenberg#75819)
- Add core/icon block to theme.json schema (WordPress/gutenberg#75813)
- Fix error when undoing newly added pattern (WordPress/gutenberg#75850)
- Page List Item: Replace RawHTML with dangerouslySetInnerHTML for label and title (WordPress/gutenberg#75890)
- REST API: Make filter_wp_unique_filename() static to match core, plus avoid duplicate routes (WordPress/gutenberg#75782)
- RichText: useAnchor: Fix TypeError in virtual element (WordPress/gutenberg#75900)
- DataViews: Remove menu divider again. (WordPress/gutenberg#75908)
- Theme: Add build plugins to inject design token fallbacks (WordPress/gutenberg#75901)
- Theme: Remove global stylesheet (WordPress/gutenberg#75879)
- Real-time collaboration: Remove ghost awareness state explicitly when refreshing (WordPress/gutenberg#75883)
- Real-time collaboration: Expand mergeCrdtBlocks() automated testing (WordPress/gutenberg#75923)
- Fix client-side media file naming (WordPress/gutenberg#75817)
- Add: Connectors screen (WordPress/gutenberg#75833)
- Merge document meta into state map (WordPress/gutenberg#75830)
- Move WordPress meta key from sync package to core-data (WordPress/gutenberg#75846)
- Bugfix: Fix casing of getPersistedCRDTDoc (WordPress/gutenberg#75922)
- Add debug logging to SyncManager (WordPress/gutenberg#75924)
- DataForm: fix label colors (WordPress/gutenberg#75730)
- DataViews: minimize padding for primary action buttons (WordPress/gutenberg#75721) (WordPress/gutenberg#75947)
- Connectors: Add `_ai_` prefix to connector setting names and fix naming inconsistencies (WordPress/gutenberg#75948)
- Connectors: Unhook Core callbacks in Gutenberg coexistence (WordPress/gutenberg#75935)
- Unsynced patterns: Rename 'Disconnect pattern' to 'Detach pattern' in context menu (WordPress/gutenberg#75807)
- Editor: Remove View dropdown and pinned items from revisions header (WordPress/gutenberg#75951)
- Fix: Template revisions infinite spinner (WordPress/gutenberg#75953)
- Backport: Avoid flickering while refreshing (WordPress/gutenberg#74572) (WordPress/gutenberg#75952)
- Add wp_ prefix to real time collaberation option. (WordPress/gutenberg#75837)


git-svn-id: https://develop.svn.wordpress.org/trunk@61750 602fd350-edb4-49c9-b593-d223f7449a82
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Feb 26, 2026
CI run: WordPress/wordpress-develop#11059.

See #64595.

---

I've included a log of the Gutenberg changes with the following command:

git log --reverse --format="- %s" 23b566c72e9c4a36219ef5d6e62890f05551f6cb..022d8dd3d461f91b15c1f0410649d3ebb027207f | sed 's|#\([0-9][0-9]*\)|https://github.com/WordPress/gutenberg/pull/\1|g; /github\.com\/WordPress\/gutenberg\/pull/!d' | pbcopy

- Pattern Editing: Fix nested patterns/sections (WordPress/gutenberg#75772)
- QuickEdit: rename status label and remove extra labels in popup (WordPress/gutenberg#75824)
- Fix block editing modes not recomputing when isolated editor value changes (WordPress/gutenberg#75821)
- Synced patterns: Fix block editing mode of synced pattern content when nested in an unsynced pattern (WordPress/gutenberg#75818)
- Block Support: Fix custom CSS not saved when style schema is not defined (WordPress/gutenberg#75797)
- Gallery: Fixes keyboard focus escaping the lightbox overlay when navigating a gallery with Tab/Shift+Tab. (WordPress/gutenberg#75852)
- Navigation Overlay Close: Set Close as default text, rather than using a placeholder (WordPress/gutenberg#75692)
- RTC: Fix entity save call / initial persistence. (WordPress/gutenberg#75841)
- Real-time collaboration: Improve collaboration within the same rich text (WordPress/gutenberg#75703)
- Client Side Media: Add device/browser capability detection (WordPress/gutenberg#75863)
- Navigation editing: simplify edit/view buttons (WordPress/gutenberg#75819)
- Add core/icon block to theme.json schema (WordPress/gutenberg#75813)
- Fix error when undoing newly added pattern (WordPress/gutenberg#75850)
- Page List Item: Replace RawHTML with dangerouslySetInnerHTML for label and title (WordPress/gutenberg#75890)
- REST API: Make filter_wp_unique_filename() static to match core, plus avoid duplicate routes (WordPress/gutenberg#75782)
- RichText: useAnchor: Fix TypeError in virtual element (WordPress/gutenberg#75900)
- DataViews: Remove menu divider again. (WordPress/gutenberg#75908)
- Theme: Add build plugins to inject design token fallbacks (WordPress/gutenberg#75901)
- Theme: Remove global stylesheet (WordPress/gutenberg#75879)
- Real-time collaboration: Remove ghost awareness state explicitly when refreshing (WordPress/gutenberg#75883)
- Real-time collaboration: Expand mergeCrdtBlocks() automated testing (WordPress/gutenberg#75923)
- Fix client-side media file naming (WordPress/gutenberg#75817)
- Add: Connectors screen (WordPress/gutenberg#75833)
- Merge document meta into state map (WordPress/gutenberg#75830)
- Move WordPress meta key from sync package to core-data (WordPress/gutenberg#75846)
- Bugfix: Fix casing of getPersistedCRDTDoc (WordPress/gutenberg#75922)
- Add debug logging to SyncManager (WordPress/gutenberg#75924)
- DataForm: fix label colors (WordPress/gutenberg#75730)
- DataViews: minimize padding for primary action buttons (WordPress/gutenberg#75721) (WordPress/gutenberg#75947)
- Connectors: Add `_ai_` prefix to connector setting names and fix naming inconsistencies (WordPress/gutenberg#75948)
- Connectors: Unhook Core callbacks in Gutenberg coexistence (WordPress/gutenberg#75935)
- Unsynced patterns: Rename 'Disconnect pattern' to 'Detach pattern' in context menu (WordPress/gutenberg#75807)
- Editor: Remove View dropdown and pinned items from revisions header (WordPress/gutenberg#75951)
- Fix: Template revisions infinite spinner (WordPress/gutenberg#75953)
- Backport: Avoid flickering while refreshing (WordPress/gutenberg#74572) (WordPress/gutenberg#75952)
- Add wp_ prefix to real time collaberation option. (WordPress/gutenberg#75837)

Built from https://develop.svn.wordpress.org/trunk@61750


git-svn-id: http://core.svn.wordpress.org/trunk@61056 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backported to WP Core Pull request that has been successfully merged into WP Core [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration [Package] Sync [Type] Bug An existing feature does not function as intended

3 participants