From b0d1a83165f64efdcf8b396f99ad0bb0cfb60420 Mon Sep 17 00:00:00 2001 From: Jignesh Bhavani <9113429+jigneshbhavani@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:22:01 +0530 Subject: [PATCH 1/2] fix: merge the post lock entry into the editor's presence entry Opening a post editor wrote two rows to the same post room for the same user, editor-{user_id} from the editor heartbeat and lock-{user_id} from the post-lock bridge. Both stored identical state and differed only by a client_id prefix that nothing parses. The lock is now a `locked` flag on the editor's own entry, derived from the wp-refresh-post-lock payload that arrives on the same tick. That keeps the liveness signal the split encoded: a tick carrying no refresh clears the flag, which is what the second row's expiry used to say. The bridge stands down when the editor ping already covers the post, so this removes a write per tick per editing user rather than relocating one, and drops the now-empty lock- client from the pagehide cleanup. Fixes #134 --- includes/heartbeat.php | 41 ++++-- includes/post-lock-bridge.php | 32 +++-- tests/test-heartbeat.php | 217 ++++++++++++++++++++++++++++++++ tests/test-post-lock-bridge.php | 62 ++++++++- 4 files changed, 326 insertions(+), 26 deletions(-) diff --git a/includes/heartbeat.php b/includes/heartbeat.php index db6c81b..6b84dbd 100644 --- a/includes/heartbeat.php +++ b/includes/heartbeat.php @@ -9,6 +9,25 @@ exit; } +/** + * Builds the state stored on a user's entry in a post room. + * + * Shared so the two writers of that entry cannot drift apart on its shape. + * + * @access private + * + * @param string $screen_id The screen ID. + * @param bool $locked Whether this write carries a post lock refresh. + * @return array The state to store. + */ +function wp_presence_editor_state( $screen_id, $locked ) { + return array( + 'action' => 'editing', + 'screen' => $screen_id, + 'locked' => (bool) $locked, + ); +} + /** * Enqueues heartbeat and the presence ping script on all admin pages. */ @@ -75,11 +94,6 @@ function wp_presence_enqueue_heartbeat_ping() { 'room' => $room, 'client_id' => 'editor-' . $user_id, ); - // The post-lock bridge writes this entry via the wp-refresh-post-lock heartbeat. - $entries[] = array( - 'room' => $room, - 'client_id' => 'lock-' . $user_id, - ); } } } @@ -105,13 +119,12 @@ function wp_presence_enqueue_heartbeat_ping() { if ( $editor_post_id ) { $editor_room = wp_presence_post_room( $editor_post_id ); if ( $editor_room ) { + // No tick has carried a lock refresh yet. connectNow() on load makes + // that a single request, not a visible state. wp_set_presence( $editor_room, 'editor-' . $user_id, - array( - 'action' => 'editing', - 'screen' => $screen_id, - ), + wp_presence_editor_state( $screen_id, false ), $user_id ); } @@ -235,13 +248,15 @@ function wp_presence_editor_heartbeat_received( $response, $data, $screen_id ) { return $response; } + // Read per tick: a tick carrying no refresh means the lock is no longer + // being held open, which is what the separate row's expiry used to say. + $locked = ! empty( $data['wp-refresh-post-lock']['post_id'] ) + && absint( $data['wp-refresh-post-lock']['post_id'] ) === $post_id; + wp_set_presence( $room, 'editor-' . $user_id, - array( - 'action' => 'editing', - 'screen' => $screen_id, - ), + wp_presence_editor_state( $screen_id, $locked ), $user_id ); diff --git a/includes/post-lock-bridge.php b/includes/post-lock-bridge.php index ff3906d..068f96d 100644 --- a/includes/post-lock-bridge.php +++ b/includes/post-lock-bridge.php @@ -2,11 +2,12 @@ /** * Post-lock bridge: writes presence entries alongside post lock heartbeats. * - * This bridge is transitional. It creates presence entries alongside the - * existing _edit_lock postmeta so both systems coexist. The intent is for - * the block editor (Gutenberg) to consume presence data directly in the - * future — enabling real-time awareness (cursors, selections, who's editing - * which block) rather than the current blunt lock/takeover model. + * This bridge is transitional. It records the lock on the editing user's + * presence entry alongside the existing _edit_lock postmeta so both systems + * coexist. The intent is for the block editor (Gutenberg) to consume presence + * data directly in the future — enabling real-time awareness (cursors, + * selections, who's editing which block) rather than the current blunt + * lock/takeover model. * * @package Presence_API */ @@ -18,8 +19,11 @@ /** * Bridges post-lock heartbeats into presence entries. * - * Writes a presence entry alongside the existing _edit_lock postmeta - * whenever a post lock is refreshed via Heartbeat. + * Marks the user's entry in the post room as holding the lock whenever a post + * lock is refreshed via Heartbeat, alongside the existing _edit_lock postmeta. + * + * Fallback path, for a client that refreshes the core lock without sending + * presence-editor-ping. * * @param array $response The Heartbeat response. * @param array $data The $_POST data sent. @@ -34,6 +38,13 @@ function wp_presence_bridge_post_lock( $response, $data, $screen_id ) { } $post_id = absint( $data['wp-refresh-post-lock']['post_id'] ); + + // The editor handler already wrote this entry from this same payload. + if ( ! empty( $data['presence-editor-ping']['post_id'] ) + && absint( $data['presence-editor-ping']['post_id'] ) === $post_id ) { + return $response; + } + $user_id = get_current_user_id(); if ( ! $user_id || ! current_user_can( 'edit_post', $post_id ) ) { @@ -48,11 +59,8 @@ function wp_presence_bridge_post_lock( $response, $data, $screen_id ) { wp_set_presence( $room, - 'lock-' . $user_id, - array( - 'action' => 'editing', - 'screen' => $screen_id, - ), + 'editor-' . $user_id, + wp_presence_editor_state( $screen_id, true ), $user_id ); diff --git a/tests/test-heartbeat.php b/tests/test-heartbeat.php index f693372..ce4ff95 100644 --- a/tests/test-heartbeat.php +++ b/tests/test-heartbeat.php @@ -168,4 +168,221 @@ public function test_wp_presence_enqueue_heartbeat_ping() { } $this->assertTrue( $found_config ); } + + /** + * @covers ::wp_presence_editor_heartbeat_received + */ + public function test_editor_heartbeat_writes_presence() { + $post_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + $response = wp_presence_editor_heartbeat_received( + array( 'existing' => true ), + array( + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + ), + 'post' + ); + + $entries = wp_get_presence( wp_presence_post_room( $post_id ) ); + + $this->assertCount( 1, $entries ); + $this->assertSame( 'editor-' . self::$editor_id, $entries[0]->client_id ); + $this->assertSame( 'editing', $entries[0]->data['action'] ); + $this->assertSame( 'post', $entries[0]->data['screen'] ); + + $this->assertSame( array( 'existing' => true ), $response ); + } + + /** + * @covers ::wp_presence_editor_heartbeat_received + */ + public function test_editor_heartbeat_requires_edit_cap() { + $post_id = self::factory()->post->create(); + + wp_set_current_user( self::$subscriber_id ); + + wp_presence_editor_heartbeat_received( + array(), + array( + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + ), + 'post' + ); + + $this->assertCount( 0, wp_get_presence( wp_presence_post_room( $post_id ) ) ); + } + + /** + * @covers ::wp_presence_editor_heartbeat_received + */ + public function test_editor_heartbeat_marks_locked_when_the_post_lock_refreshes() { + $post_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + wp_presence_editor_heartbeat_received( + array(), + array( + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + 'wp-refresh-post-lock' => array( + 'post_id' => $post_id, + ), + ), + 'post' + ); + + $entries = wp_get_presence( wp_presence_post_room( $post_id ) ); + + $this->assertCount( 1, $entries ); + $this->assertTrue( $entries[0]->data['locked'] ); + } + + /** + * A tick without a lock refresh is what a stale lock now looks like, so the + * flag has to go back to false rather than linger from the previous write. + * + * @covers ::wp_presence_editor_heartbeat_received + */ + public function test_editor_heartbeat_clears_locked_without_a_post_lock_refresh() { + $post_id = self::factory()->post->create(); + $payload = array( + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + 'wp-refresh-post-lock' => array( + 'post_id' => $post_id, + ), + ); + + wp_set_current_user( self::$editor_id ); + + wp_presence_editor_heartbeat_received( array(), $payload, 'post' ); + + unset( $payload['wp-refresh-post-lock'] ); + wp_presence_editor_heartbeat_received( array(), $payload, 'post' ); + + $entries = wp_get_presence( wp_presence_post_room( $post_id ) ); + + $this->assertCount( 1, $entries ); + $this->assertFalse( $entries[0]->data['locked'] ); + } + + /** + * The lock refresh is for a post other than the one being pinged, so it says + * nothing about this room. + * + * @covers ::wp_presence_editor_heartbeat_received + */ + public function test_editor_heartbeat_ignores_a_lock_refresh_for_another_post() { + $post_id = self::factory()->post->create(); + $other_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + wp_presence_editor_heartbeat_received( + array(), + array( + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + 'wp-refresh-post-lock' => array( + 'post_id' => $other_id, + ), + ), + 'post' + ); + + $entries = wp_get_presence( wp_presence_post_room( $post_id ) ); + + $this->assertCount( 1, $entries ); + $this->assertFalse( $entries[0]->data['locked'] ); + } + + /** + * Regression guard for the double entry: one person in one editor is one + * row in the post room, however many heartbeat handlers see the tick. + * + * @covers ::wp_presence_editor_heartbeat_received + * @covers ::wp_presence_bridge_post_lock + */ + public function test_one_editing_user_occupies_one_row() { + $post_id = self::factory()->post->create(); + $payload = array( + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + 'wp-refresh-post-lock' => array( + 'post_id' => $post_id, + ), + ); + + wp_set_current_user( self::$editor_id ); + + wp_presence_editor_heartbeat_received( array(), $payload, 'post' ); + wp_presence_bridge_post_lock( array(), $payload, 'post' ); + + $this->assertCount( 1, wp_get_presence( wp_presence_post_room( $post_id ) ) ); + } + + /** + * With one entry per editing user there is no second client to clean up on + * pagehide, and registering one would DELETE a row that never existed. + * + * @covers ::wp_presence_enqueue_heartbeat_ping + */ + public function test_pagehide_entries_omit_a_separate_lock_client() { + global $post; + + $post_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + $post = get_post( $post_id ); + set_current_screen( 'post' ); + + // wp_add_inline_script() appends, so a config printed by an earlier test + // would still be sitting in `extra` and would be the one read back. + wp_deregister_script( 'wp-presence-ping' ); + + $wp_scripts = wp_scripts(); + $wp_scripts->queue = array(); + $wp_scripts->done = array(); + + wp_presence_enqueue_heartbeat_ping(); + + $config = $this->get_ping_config(); + $client_ids = wp_list_pluck( $config['entries'], 'client_id' ); + + $this->assertContains( 'editor-' . self::$editor_id, $client_ids ); + $this->assertNotContains( 'lock-' . self::$editor_id, $client_ids ); + } + + /** + * Decodes the wpPresenceConfig object handed to presence-ping.js. + * + * @return array The decoded config. + */ + private function get_ping_config() { + $extra = wp_scripts()->registered['wp-presence-ping']->extra; + + foreach ( $extra['before'] as $script ) { + if ( ! $script || false === strpos( $script, 'window.wpPresenceConfig =' ) ) { + continue; + } + + $json = trim( substr( $script, strpos( $script, '=' ) + 1 ) ); + + return json_decode( rtrim( $json, ';' ), true ); + } + + $this->fail( 'The presence ping config was not printed.' ); + } } diff --git a/tests/test-post-lock-bridge.php b/tests/test-post-lock-bridge.php index 803af78..3e18839 100644 --- a/tests/test-post-lock-bridge.php +++ b/tests/test-post-lock-bridge.php @@ -60,6 +60,66 @@ public function test_post_lock_bridge_creates_presence() { $entries = wp_get_presence( $room ); $this->assertCount( 1, $entries ); - $this->assertSame( 'lock-' . self::$editor_id, $entries[0]->client_id ); + $this->assertSame( 'editor-' . self::$editor_id, $entries[0]->client_id ); + $this->assertTrue( $entries[0]->data['locked'] ); + } + + /** + * The editor handler writes the same entry from the same payload, so the + * bridge writing again would only cost a second query for the same row. + * + * @covers ::wp_presence_bridge_post_lock + */ + public function test_post_lock_bridge_defers_to_the_editor_ping() { + $post_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + wp_presence_bridge_post_lock( + array(), + array( + 'wp-refresh-post-lock' => array( + 'post_id' => $post_id, + ), + 'presence-editor-ping' => array( + 'post_id' => $post_id, + ), + ), + 'post' + ); + + $this->assertCount( + 0, + wp_get_presence( wp_presence_post_room( $post_id ) ), + 'The bridge should stand down when the editor ping already covers this post.' + ); + } + + /** + * @covers ::wp_presence_bridge_post_lock + */ + public function test_post_lock_bridge_writes_when_the_editor_ping_is_for_another_post() { + $locked_id = self::factory()->post->create(); + $other_id = self::factory()->post->create(); + + wp_set_current_user( self::$editor_id ); + + wp_presence_bridge_post_lock( + array(), + array( + 'wp-refresh-post-lock' => array( + 'post_id' => $locked_id, + ), + 'presence-editor-ping' => array( + 'post_id' => $other_id, + ), + ), + 'post' + ); + + $entries = wp_get_presence( wp_presence_post_room( $locked_id ) ); + + $this->assertCount( 1, $entries ); + $this->assertSame( 'editor-' . self::$editor_id, $entries[0]->client_id ); } } From da77dfb262e1b74d23defea4cb07905387abb5cf Mon Sep 17 00:00:00 2001 From: Jignesh Bhavani <9113429+jigneshbhavani@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:24:46 +0530 Subject: [PATCH 2/2] fix: count people rather than rows in the Active Posts widget The merge above removes the duplicate row this widget was counting, but nothing guarantees one row per user in a room, and this is the only aggregation path that did not already dedupe. wp_get_active_rooms(), the post list column and the admin bar all key by user id. Rows arrive newest first, so the first entry seen for a user is the freshest and decides their status. Refs #134 --- .../class-wp-presence-widget-active-posts.php | 17 +++- tests/widgets/test-widget-active-posts.php | 79 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/includes/widgets/class-wp-presence-widget-active-posts.php b/includes/widgets/class-wp-presence-widget-active-posts.php index fe909d4..9ac0ca8 100644 --- a/includes/widgets/class-wp-presence-widget-active-posts.php +++ b/includes/widgets/class-wp-presence-widget-active-posts.php @@ -371,8 +371,16 @@ private static function build_active_posts_data() { ); } - $by_post[ $post_id ]['editors'][] = array( - 'user_id' => (int) $entry->user_id, + $editor_id = (int) $entry->user_id; + + // A user can hold more than one entry in a room. Rows arrive newest + // first, so the one already seen is the freshest. + if ( isset( $by_post[ $post_id ]['editors'][ $editor_id ] ) ) { + continue; + } + + $by_post[ $post_id ]['editors'][ $editor_id ] = array( + 'user_id' => $editor_id, 'display_name' => $user->display_name, 'avatar_url' => get_avatar_url( $user->ID, array( 'size' => 24 ) ), 'status' => $status, @@ -387,6 +395,11 @@ function ( $a, $b ) { } ); + // Keyed by user id above; the response is JSON, so hand back a list. + foreach ( $by_post as $index => $post_data ) { + $by_post[ $index ]['editors'] = array_values( $post_data['editors'] ); + } + return array_values( $by_post ); } } diff --git a/tests/widgets/test-widget-active-posts.php b/tests/widgets/test-widget-active-posts.php index 0fa6a80..0133d27 100644 --- a/tests/widgets/test-widget-active-posts.php +++ b/tests/widgets/test-widget-active-posts.php @@ -236,4 +236,83 @@ public function test_heartbeat_filters_per_post_rather_than_all_or_nothing() { $this->assertCount( 1, $response['presence-active-posts'] ); $this->assertSame( $draft_id, $response['presence-active-posts'][0]['post_id'] ); } + + /** + * Nothing guarantees one row per user in a room, so the widget counts + * people rather than rows. + * + * @covers WP_Presence_Widget_Active_Posts::heartbeat_received + */ + public function test_a_user_holding_two_entries_is_counted_once() { + wp_set_current_user( self::$editor_id ); + + $room = wp_presence_post_room( self::$post_id ); + wp_set_presence( $room, 'editor-' . self::$editor_id, array(), self::$editor_id ); + wp_set_presence( $room, 'other-' . self::$editor_id, array(), self::$editor_id ); + + $response = WP_Presence_Widget_Active_Posts::heartbeat_received( + array(), + array( 'presence-active-posts-ping' => true ), + 'dashboard' + ); + + $this->assertCount( 1, $response['presence-active-posts'][0]['editors'] ); + $this->assertSame( self::$editor_id, $response['presence-active-posts'][0]['editors'][0]['user_id'] ); + } + + /** + * @covers WP_Presence_Widget_Active_Posts::heartbeat_received + */ + public function test_a_users_freshest_entry_decides_their_status() { + global $wpdb; + + wp_set_current_user( self::$editor_id ); + + $room = wp_presence_post_room( self::$post_id ); + + wp_set_presence( $room, 'stale-' . self::$editor_id, array(), self::$editor_id ); + $wpdb->update( + $wpdb->presence, + array( 'date_gmt' => gmdate( 'Y-m-d H:i:s', time() - 45 ) ), + array( 'client_id' => 'stale-' . self::$editor_id ), + array( '%s' ), + array( '%s' ) + ); + + wp_set_presence( $room, 'editor-' . self::$editor_id, array(), self::$editor_id ); + + $response = WP_Presence_Widget_Active_Posts::heartbeat_received( + array(), + array( 'presence-active-posts-ping' => true ), + 'dashboard' + ); + + $editors = $response['presence-active-posts'][0]['editors']; + + $this->assertCount( 1, $editors ); + $this->assertSame( 'active', $editors[0]['status'] ); + } + + /** + * The response is JSON, so the editors list has to stay a list. + * + * @covers WP_Presence_Widget_Active_Posts::heartbeat_received + */ + public function test_editors_encode_as_a_json_array() { + wp_set_current_user( self::$editor_id ); + + $room = wp_presence_post_room( self::$post_id ); + wp_set_presence( $room, 'editor-' . self::$editor_id, array(), self::$editor_id ); + wp_set_presence( $room, 'other-' . self::$editor_id, array(), self::$editor_id ); + + $response = WP_Presence_Widget_Active_Posts::heartbeat_received( + array(), + array( 'presence-active-posts-ping' => true ), + 'dashboard' + ); + + $encoded = wp_json_encode( $response['presence-active-posts'][0]['editors'] ); + + $this->assertStringStartsWith( '[', $encoded ); + } }