From b95b98b536551a38b27c893ffa55842911e7f6b5 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Wed, 6 May 2026 15:26:30 +0530 Subject: [PATCH 1/2] Heartbeat: Expose post lock window to JS to prevent background tab race --- src/js/_enqueues/wp/heartbeat.js | 23 ++++++++++++++++++++--- src/wp-admin/includes/misc.php | 5 ++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/js/_enqueues/wp/heartbeat.js b/src/js/_enqueues/wp/heartbeat.js index 65635177d9f66..a286a9ecee826 100644 --- a/src/js/_enqueues/wp/heartbeat.js +++ b/src/js/_enqueues/wp/heartbeat.js @@ -100,7 +100,12 @@ // Timer that keeps track of how long needs to be waited before connecting to // the server again. - beatTimer: 0 + beatTimer: 0, + + // Post lock window in milliseconds. Synced from PHP via heartbeat_settings. + // Used to calculate the background tab heartbeat interval. + // Default matches the wp_check_post_lock_window default of 150 seconds. + postLockWindow: 150000 }; /** @@ -112,7 +117,7 @@ * @return {void} */ function initialize() { - var options, hidden, visibilityState, visibilitychange; + var options, hidden, visibilityState, visibilitychange, postLockWindow; if ( typeof window.pagenow === 'string' ) { settings.screenId = window.pagenow; @@ -172,6 +177,14 @@ if ( options.suspension === 'disable' ) { settings.suspendEnabled = false; } + + if ( options.post_lock_window ) { + postLockWindow = parseInt( options.post_lock_window, 10 ); + + if ( postLockWindow > 0 ) { + settings.postLockWindow = postLockWindow * 1000; + } + } } // Convert to milliseconds. @@ -509,7 +522,11 @@ } if ( ! settings.hasFocus ) { - interval = 120000; // 120 seconds. Post locks expire after 150 seconds. + // Fire 30 seconds before the post lock window expires so backgrounded + // tabs always refresh the lock in time. For the default 150s window this + // equals 120s, preserving existing behaviour. Floored at 5s for very + // short custom windows. + interval = Math.max( 5000, settings.postLockWindow - 30000 ); } else if ( settings.countdown > 0 && settings.tempInterval ) { interval = settings.tempInterval; settings.countdown--; diff --git a/src/wp-admin/includes/misc.php b/src/wp-admin/includes/misc.php index 3724684ffd428..1b223143f5947 100644 --- a/src/wp-admin/includes/misc.php +++ b/src/wp-admin/includes/misc.php @@ -1334,7 +1334,7 @@ function wp_refresh_heartbeat_nonces( $response ) { } /** - * Disables suspension of Heartbeat on the Add/Edit Post screens. + * Disables suspension of Heartbeat and sets post lock data on the Add/Edit Post screens. * * @since 3.8.0 * @@ -1348,6 +1348,9 @@ function wp_heartbeat_set_suspension( $settings ) { if ( 'post.php' === $pagenow || 'post-new.php' === $pagenow ) { $settings['suspension'] = 'disable'; + + /** This filter is documented in wp-admin/includes/ajax-actions.php */ + $settings['post_lock_window'] = apply_filters( 'wp_check_post_lock_window', 150 ); } return $settings; From 512d4ef41768d02b9f8befd6727e242cbe172232 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 13 May 2026 08:43:50 -0700 Subject: [PATCH 2/2] Tests: Cover post_lock_window in wp_heartbeat_set_suspension(). Add coverage for the heartbeat setting exposed in [r/PR #11732]: - post_lock_window is set to the default 150 on post.php and post-new.php. - post_lock_window is omitted on non-post screens (index.php, edit.php). - post_lock_window reflects the wp_check_post_lock_window filter value. See #65171. --- .../misc/WpHeartbeatSetSuspension_Test.php | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php b/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php index e799efda53353..739991f8a8fe5 100644 --- a/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php +++ b/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php @@ -79,4 +79,89 @@ public function data_wp_heartbeat_set_suspension(): array { ), ); } + + /** + * Tests that wp_heartbeat_set_suspension() exposes the default post lock window on post screens. + * + * @dataProvider data_post_screens + * + * @ticket 65171 + * + * @param string $pagenow_value The value for the $pagenow global. + */ + public function test_wp_heartbeat_set_suspension_sets_post_lock_window_on_post_screens( $pagenow_value ) { + global $pagenow; + + $pagenow = $pagenow_value; + + $result = wp_heartbeat_set_suspension( array() ); + + $this->assertArrayHasKey( 'post_lock_window', $result, "'post_lock_window' should be present when \$pagenow is {$pagenow_value}." ); + $this->assertSame( 150, $result['post_lock_window'], "'post_lock_window' should default to 150 when \$pagenow is {$pagenow_value}." ); + } + + /** + * Tests that wp_heartbeat_set_suspension() does not expose a post lock window on non-post screens. + * + * @dataProvider data_non_post_screens + * + * @ticket 65171 + * + * @param string $pagenow_value The value for the $pagenow global. + */ + public function test_wp_heartbeat_set_suspension_does_not_set_post_lock_window_on_other_screens( $pagenow_value ) { + global $pagenow; + + $pagenow = $pagenow_value; + + $result = wp_heartbeat_set_suspension( array() ); + + $this->assertArrayNotHasKey( 'post_lock_window', $result, "'post_lock_window' should not be set when \$pagenow is {$pagenow_value}." ); + } + + /** + * Tests that wp_heartbeat_set_suspension() honors the wp_check_post_lock_window filter. + * + * @ticket 65171 + */ + public function test_wp_heartbeat_set_suspension_post_lock_window_respects_filter() { + global $pagenow; + + $pagenow = 'post.php'; + + add_filter( + 'wp_check_post_lock_window', + static function () { + return 60; + } + ); + + $result = wp_heartbeat_set_suspension( array() ); + + $this->assertSame( 60, $result['post_lock_window'], "'post_lock_window' should reflect the wp_check_post_lock_window filter value." ); + } + + /** + * Data provider: $pagenow values for the Add/Edit Post screens. + * + * @return array + */ + public function data_post_screens(): array { + return array( + 'post.php' => array( 'pagenow_value' => 'post.php' ), + 'post-new.php' => array( 'pagenow_value' => 'post-new.php' ), + ); + } + + /** + * Data provider: $pagenow values for screens that are not Add/Edit Post. + * + * @return array + */ + public function data_non_post_screens(): array { + return array( + 'index.php' => array( 'pagenow_value' => 'index.php' ), + 'edit.php' => array( 'pagenow_value' => 'edit.php' ), + ); + } }