From 270efac8b6573d710250e0f11ff520133fd4b7a8 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Wed, 26 Aug 2026 11:46:49 +0530 Subject: [PATCH] Administration: Refresh stale post list tables --- src/js/_enqueues/admin/inline-edit-post.js | 12 ++ src/wp-admin/edit.php | 3 +- src/wp-admin/includes/admin-filters.php | 1 + src/wp-admin/includes/misc.php | 34 +++ src/wp-includes/default-filters.php | 10 + src/wp-includes/post.php | 140 +++++++++++++ tests/e2e/specs/edit-posts.test.js | 78 ++++++- .../misc/WpHeartbeatSetSuspension_Test.php | 193 ++++++++++++++++++ 8 files changed, 469 insertions(+), 2 deletions(-) diff --git a/src/js/_enqueues/admin/inline-edit-post.js b/src/js/_enqueues/admin/inline-edit-post.js index 6e6e3bef606ed..d4203c56e7962 100644 --- a/src/js/_enqueues/admin/inline-edit-post.js +++ b/src/js/_enqueues/admin/inline-edit-post.js @@ -621,6 +621,13 @@ $( function() { var locked = data['wp-check-locked-posts'] || {}, lockedClass = 'wp-locked'; + // A post change can affect filtering, ordering, and pagination. + // Defer reloading while an inline editor has unsaved changes. + if ( data['wp-refresh-post-list'] && ! $( '#the-list tr.inline-editor' ).length ) { + window.location.reload(); + return; + } + $('#the-list tr').each( function(i, el) { var key = el.id, row = $(el), lock_data, avatar; @@ -659,6 +666,11 @@ $( function() { if ( check.length ) { data['wp-check-locked-posts'] = check; } + + data['wp-check-post-list'] = { + post_type: $( '.post_type_page' ).val(), + last_changed: $( '#posts-filter' ).attr( 'data-wp-post-list-last-changed' ) + }; }); })( jQuery, window.wp ); diff --git a/src/wp-admin/edit.php b/src/wp-admin/edit.php index 15cb17d303bd3..660eab8cbc5ad 100644 --- a/src/wp-admin/edit.php +++ b/src/wp-admin/edit.php @@ -232,6 +232,7 @@ exit; } +$post_list_table_last_changed = _wp_get_post_list_table_last_changed( $post_type ); $wp_list_table->prepare_items(); wp_enqueue_script( 'inline-edit-post' ); @@ -485,7 +486,7 @@ views(); ?> -
+ search_box( $post_type_object->labels->search_items, 'post' ); ?> diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index 5337cc02c88c9..feb3851c49586 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -76,6 +76,7 @@ add_action( 'update_option_new_admin_email', 'update_option_new_admin_email', 10, 2 ); add_filter( 'heartbeat_received', 'wp_check_locked_posts', 10, 3 ); +add_filter( 'heartbeat_received', 'wp_check_post_list_table_changes', 10, 2 ); add_filter( 'heartbeat_received', 'wp_refresh_post_lock', 10, 3 ); add_filter( 'heartbeat_received', 'heartbeat_autosave', 500, 2 ); diff --git a/src/wp-admin/includes/misc.php b/src/wp-admin/includes/misc.php index f021aedb8a5fb..1726a0a104f3c 100644 --- a/src/wp-admin/includes/misc.php +++ b/src/wp-admin/includes/misc.php @@ -1176,6 +1176,40 @@ function wp_check_locked_posts( $response, $data, $screen_id ) { return $response; } +/** + * Checks whether a post list table has changed. + * + * @since x.x.x + * + * @param array $response The Heartbeat response. + * @param array $data The $_POST data sent. + * @return array The Heartbeat response. + */ +function wp_check_post_list_table_changes( $response, $data ) { + if ( empty( $data['wp-check-post-list'] ) || ! is_array( $data['wp-check-post-list'] ) ) { + return $response; + } + + $check = $data['wp-check-post-list']; + + if ( empty( $check['post_type'] ) || ! is_string( $check['post_type'] ) || ! array_key_exists( 'last_changed', $check ) || ! is_string( $check['last_changed'] ) ) { + return $response; + } + + $post_type = sanitize_key( $check['post_type'] ); + $post_type_object = get_post_type_object( $post_type ); + + if ( 'attachment' === $post_type || ! $post_type_object || ! $post_type_object->show_ui || ! current_user_can( $post_type_object->cap->edit_posts ) ) { + return $response; + } + + if ( _wp_get_post_list_table_last_changed( $post_type ) !== $check['last_changed'] ) { + $response['wp-refresh-post-list'] = true; + } + + return $response; +} + /** * Checks lock status on the New/Edit Post screen and refresh the lock. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..2ea1b4914fdac 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -444,6 +444,16 @@ // Create a revision whenever a post is updated. add_action( 'wp_after_insert_post', 'wp_save_post_revision_on_insert', 9, 3 ); add_action( 'post_updated', 'wp_save_post_revision', 10, 1 ); + +// Update post list table change tokens after post mutations. +add_action( 'wp_after_insert_post', '_wp_set_post_list_table_last_changed', 10, 4 ); +add_action( 'deleted_post', '_wp_set_post_list_table_last_changed', 10, 2 ); +add_action( 'added_post_meta', '_wp_set_post_list_table_last_changed_for_post_meta', 10, 2 ); +add_action( 'updated_post_meta', '_wp_set_post_list_table_last_changed_for_post_meta', 10, 2 ); +add_action( 'deleted_post_meta', '_wp_set_post_list_table_last_changed_for_post_meta', 10, 2 ); +add_action( 'added_term_relationship', '_wp_set_post_list_table_last_changed_for_terms', 10, 3 ); +add_action( 'deleted_term_relationships', '_wp_set_post_list_table_last_changed_for_terms', 10, 3 ); +add_action( 'shutdown', '_wp_finalize_post_list_table_last_changed' ); add_action( 'publish_post', '_publish_post_hook', 5, 1 ); add_action( 'transition_post_status', '_transition_post_status', 5, 3 ); add_action( 'transition_post_status', '_update_term_count_on_transition_post_status', 10, 3 ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2db73e9a20476..04bd7dcd0e628 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8634,6 +8634,146 @@ function wp_cache_set_posts_last_changed() { wp_cache_set_last_changed( 'posts' ); } +/** + * Retrieves the last changed token for a post type's list table. + * + * For internal use. + * + * @since x.x.x + * @access private + * + * @param string $post_type Post type. + * @return string Last changed token, or an empty string if no changes have been recorded. + */ +function _wp_get_post_list_table_last_changed( $post_type ) { + return get_option( '_wp_post_list_table_last_changed_' . sanitize_key( $post_type ), '' ); +} + +/** + * Marks post list table change tokens for update. + * + * The first post save or delete updates its token immediately. Standalone meta + * and term mutations, and additional changes, are finalized at shutdown. + * + * For internal use. + * + * @since x.x.x + * @access private + * + * @global array $_wp_post_list_table_changed + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @param bool|null $update Whether this is an existing post being updated. + * @param WP_Post|null $post_before Post object before the update, or null. + * @param bool $defer Whether to defer the first token update until shutdown. + */ +function _wp_set_post_list_table_last_changed( $post_id, $post, $update = null, $post_before = null, $defer = false ) { + global $_wp_post_list_table_changed; + + if ( 'auto-draft' === $post->post_status && ( ! $post_before || 'auto-draft' === $post_before->post_status ) ) { + return; + } + + $post_types = array( $post->post_type ); + + if ( $post_before && $post_before->post_type !== $post->post_type ) { + $post_types[] = $post_before->post_type; + } + + foreach ( $post_types as $post_type ) { + $post_type_object = get_post_type_object( $post_type ); + + if ( 'attachment' === $post_type || ! $post_type_object || ! $post_type_object->show_ui ) { + continue; + } + + if ( ! isset( $_wp_post_list_table_changed[ $post_type ] ) && $defer ) { + $_wp_post_list_table_changed[ $post_type ] = 'pending'; + continue; + } + + if ( isset( $_wp_post_list_table_changed[ $post_type ] ) && 'pending' === $_wp_post_list_table_changed[ $post_type ] && $defer ) { + continue; + } + + if ( ! isset( $_wp_post_list_table_changed[ $post_type ] ) || 'pending' === $_wp_post_list_table_changed[ $post_type ] ) { + $_wp_post_list_table_changed[ $post_type ] = false; + + if ( ! update_option( '_wp_post_list_table_last_changed_' . $post_type, wp_generate_uuid4(), false ) && false === $_wp_post_list_table_changed[ $post_type ] ) { + $_wp_post_list_table_changed[ $post_type ] = true; + } + } else { + $_wp_post_list_table_changed[ $post_type ] = true; + } + } +} + +/** + * Marks a post list table change token after a post meta mutation. + * + * For internal use. + * + * @since x.x.x + * @access private + * + * @param int $meta_id Post meta ID. + * @param int $post_id Post ID. + */ +function _wp_set_post_list_table_last_changed_for_post_meta( $meta_id, $post_id ) { + $post = get_post( $post_id ); + + if ( $post ) { + _wp_set_post_list_table_last_changed( $post_id, $post, null, null, true ); + } +} + +/** + * Marks a post list table change token after a term relationship mutation. + * + * For internal use. + * + * @since x.x.x + * @access private + * + * @param int $post_id Post ID. + * @param int|int[] $tt_ids Term taxonomy ID or IDs. + * @param string $taxonomy Taxonomy slug. + */ +function _wp_set_post_list_table_last_changed_for_terms( $post_id, $tt_ids, $taxonomy ) { + $post = get_post( $post_id ); + + if ( $post && is_object_in_taxonomy( $post->post_type, $taxonomy ) ) { + _wp_set_post_list_table_last_changed( $post_id, $post, null, null, true ); + } +} + +/** + * Finalizes coalesced post list table change token updates. + * + * For internal use. + * + * @since x.x.x + * @access private + * + * @global array $_wp_post_list_table_changed + */ +function _wp_finalize_post_list_table_last_changed() { + global $_wp_post_list_table_changed; + + if ( empty( $_wp_post_list_table_changed ) ) { + return; + } + + foreach ( $_wp_post_list_table_changed as $post_type => $needs_update ) { + if ( $needs_update ) { + update_option( '_wp_post_list_table_last_changed_' . $post_type, wp_generate_uuid4(), false ); + } + } + + $_wp_post_list_table_changed = array(); +} + /** * Gets all available post MIME types for a given post type. * diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index 9b8cd37c60e50..e692cf08110dd 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +import { Editor, test, expect } from '@wordpress/e2e-test-utils-playwright'; test.describe( 'Edit Posts', () => { test.beforeEach( async ( { requestUtils }) => { @@ -107,6 +107,82 @@ test.describe( 'Edit Posts', () => { expect( posts.first() ).toHaveText( `${ title } Edited` ); } ); + test( 'refreshes the post list when a post is updated in another window', async ( { + admin, + page, + requestUtils, + } ) => { + const originalTitle = 'Original title'; + const updatedTitle = 'Updated title'; + const post = await requestUtils.createPost( { + title: originalTitle, + status: 'publish', + } ); + + await admin.visitAdminPage( '/edit.php' ); + const rowTitle = page.locator( `#post-${ post.id } .row-title` ); + await expect( rowTitle ).toHaveText( originalTitle ); + + const otherPage = await page.context().newPage(); + const otherEditor = new Editor( { page: otherPage } ); + await otherPage.goto( `/wp-admin/post.php?post=${ post.id }&action=edit` ); + + const welcomeDialog = otherPage.getByRole( 'dialog', { name: 'Welcome to the editor' } ); + if ( await welcomeDialog.isVisible() ) { + await welcomeDialog.getByRole( 'button', { name: 'Close' } ).click(); + } + + await otherEditor.canvas.getByRole( 'textbox', { name: 'Add title' } ).fill( updatedTitle ); + await otherPage.getByRole( 'button', { name: 'Save', exact: true } ).click(); + await expect.poll( async () => { + const updatedPost = await requestUtils.rest( { + path: `/wp/v2/posts/${ post.id }`, + params: { context: 'edit' }, + } ); + return updatedPost.title.raw; + } ).toBe( updatedTitle ); + + await otherPage.close(); + await page.bringToFront(); + await page.evaluate( () => window.wp.heartbeat.connectNow() ); + + await expect( rowTitle ).toHaveText( updatedTitle ); + } ); + + test( 'defers refreshing the post list while Quick Edit has unsaved changes', async ( { + admin, + page, + requestUtils, + } ) => { + const post = await requestUtils.createPost( { + title: 'Original title', + status: 'publish', + } ); + + await admin.visitAdminPage( '/edit.php' ); + await page.locator( `#post-${ post.id } .editinline` ).evaluate( ( button ) => button.click() ); + + const titleInput = page.locator( `#edit-${ post.id } input[name="post_title"]` ); + await titleInput.fill( 'Unsaved title' ); + await requestUtils.rest( { + method: 'POST', + path: `/wp/v2/posts/${ post.id }`, + data: { title: 'Updated title' }, + } ); + + const heartbeatResponse = page.waitForResponse( ( response ) => + response.url().includes( 'admin-ajax.php' ) && + response.request().postData()?.includes( 'wp-check-post-list' ) + ); + await page.evaluate( () => window.wp.heartbeat.connectNow() ); + await heartbeatResponse; + await expect( titleInput ).toHaveValue( 'Unsaved title' ); + + await page.locator( `#edit-${ post.id } .cancel` ).click(); + await page.evaluate( () => window.wp.heartbeat.connectNow() ); + await expect( page.locator( `#post-${ post.id } .row-title` ) ).toHaveText( 'Updated title' ); + } ); + test( 'allows an existing post to be deleted using the Trash button', async ( { admin, editor, diff --git a/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php b/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php index e799efda53353..a05ccb470b21b 100644 --- a/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php +++ b/tests/phpunit/tests/admin/includes/misc/WpHeartbeatSetSuspension_Test.php @@ -4,6 +4,11 @@ * @group admin * * @covers ::wp_heartbeat_set_suspension + * @covers ::wp_check_post_list_table_changes + * @covers ::_wp_get_post_list_table_last_changed + * @covers ::_wp_set_post_list_table_last_changed + * @covers ::_wp_set_post_list_table_last_changed_for_post_meta + * @covers ::_wp_set_post_list_table_last_changed_for_terms */ class Tests_Admin_Includes_Misc_WpHeartbeatSetSuspension_Test extends WP_UnitTestCase { @@ -19,6 +24,7 @@ public function set_up() { parent::set_up(); + _wp_finalize_post_list_table_last_changed(); $this->orig_pagenow = $pagenow; } @@ -26,10 +32,27 @@ public function tear_down() { global $pagenow; $pagenow = $this->orig_pagenow; + _wp_finalize_post_list_table_last_changed(); parent::tear_down(); } + /** + * Returns Heartbeat data for a post type and change token. + * + * @param string $post_type Post type. + * @param string $last_changed Last changed token. + * @return array Heartbeat data. + */ + private function get_post_list_heartbeat_data( $post_type, $last_changed ) { + return array( + 'wp-check-post-list' => array( + 'post_type' => $post_type, + 'last_changed' => $last_changed, + ), + ); + } + /** * Tests that wp_heartbeat_set_suspension() disables suspension on post screens. * @@ -51,6 +74,176 @@ public function test_wp_heartbeat_set_suspension( $pagenow_value, $expected ) { $this->assertSame( $expected, $result['suspension'], "Suspension should be '{$expected}' when \$pagenow is {$pagenow_value}." ); } + /** + * Tests post list Heartbeat comparisons and validation. + * + * @ticket 65461 + */ + public function test_post_list_heartbeat_comparison() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $last_changed = _wp_get_post_list_table_last_changed( 'post' ); + + $response = wp_check_post_list_table_changes( array(), $this->get_post_list_heartbeat_data( 'post', $last_changed ) ); + $this->assertArrayNotHasKey( 'wp-refresh-post-list', $response ); + + self::factory()->post->create(); + $response = wp_check_post_list_table_changes( array(), $this->get_post_list_heartbeat_data( 'post', $last_changed ) ); + $this->assertTrue( $response['wp-refresh-post-list'] ); + + $response = wp_check_post_list_table_changes( array(), $this->get_post_list_heartbeat_data( 'attachment', 'stale' ) ); + $this->assertArrayNotHasKey( 'wp-refresh-post-list', $response ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $response = wp_check_post_list_table_changes( array(), $this->get_post_list_heartbeat_data( 'post', $last_changed ) ); + $this->assertArrayNotHasKey( 'wp-refresh-post-list', $response ); + } + + /** + * Tests post lifecycle token updates. + * + * @ticket 65461 + */ + public function test_post_list_lifecycle_tokens() { + $post_token = _wp_get_post_list_table_last_changed( 'post' ); + $page_token = _wp_get_post_list_table_last_changed( 'page' ); + $post_id = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $this->assertNotSame( $post_token, _wp_get_post_list_table_last_changed( 'post' ) ); + $this->assertSame( $page_token, _wp_get_post_list_table_last_changed( 'page' ) ); + + _wp_finalize_post_list_table_last_changed(); + $first_token = _wp_get_post_list_table_last_changed( 'post' ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'First update', + ) + ); + _wp_finalize_post_list_table_last_changed(); + $second_token = _wp_get_post_list_table_last_changed( 'post' ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Second update', + ) + ); + _wp_finalize_post_list_table_last_changed(); + $third_token = _wp_get_post_list_table_last_changed( 'post' ); + + $this->assertNotSame( $first_token, $second_token ); + $this->assertNotSame( $second_token, $third_token ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'publish', + 'post_type' => 'page', + ) + ); + _wp_finalize_post_list_table_last_changed(); + $this->assertNotSame( $third_token, _wp_get_post_list_table_last_changed( 'post' ) ); + $this->assertNotSame( $page_token, _wp_get_post_list_table_last_changed( 'page' ) ); + + $post_token = _wp_get_post_list_table_last_changed( 'post' ); + $page_token = _wp_get_post_list_table_last_changed( 'page' ); + wp_insert_post( + array( + 'post_parent' => $post_id, + 'post_status' => 'inherit', + 'post_type' => 'revision', + ) + ); + wp_insert_post( + array( + 'post_status' => 'auto-draft', + 'post_type' => 'post', + ) + ); + _wp_finalize_post_list_table_last_changed(); + $this->assertSame( $post_token, _wp_get_post_list_table_last_changed( 'post' ) ); + $this->assertSame( $page_token, _wp_get_post_list_table_last_changed( 'page' ) ); + + wp_delete_post( $post_id, true ); + _wp_finalize_post_list_table_last_changed(); + $this->assertNotSame( $page_token, _wp_get_post_list_table_last_changed( 'page' ) ); + } + + /** + * Tests related-data invalidation and bounded writes. + * + * @ticket 65461 + */ + public function test_post_list_related_data_and_write_coalescing() { + $post_id = self::factory()->post->create(); + $term_id = self::factory()->category->create(); + _wp_finalize_post_list_table_last_changed(); + $last_changed = _wp_get_post_list_table_last_changed( 'post' ); + + add_post_meta( $post_id, 'list_value', 'one' ); + $this->assertSame( $last_changed, _wp_get_post_list_table_last_changed( 'post' ) ); + _wp_finalize_post_list_table_last_changed(); + $meta_token = _wp_get_post_list_table_last_changed( 'post' ); + $this->assertNotSame( $last_changed, $meta_token ); + + wp_add_object_terms( $post_id, $term_id, 'category' ); + $this->assertSame( $meta_token, _wp_get_post_list_table_last_changed( 'post' ) ); + _wp_finalize_post_list_table_last_changed(); + $this->assertNotSame( $meta_token, _wp_get_post_list_table_last_changed( 'post' ) ); + + $writes = 0; + $callback = static function ( $option ) use ( &$writes ) { + if ( '_wp_post_list_table_last_changed_post' === $option ) { + ++$writes; + } + }; + add_action( 'updated_option', $callback ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'First update', + ) + ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Second update', + ) + ); + $writes_before_shutdown = $writes; + _wp_finalize_post_list_table_last_changed(); + remove_action( 'updated_option', $callback ); + + $this->assertSame( 1, $writes_before_shutdown ); + $this->assertSame( 2, $writes ); + } + + /** + * Tests that a failed immediate token update is retried at shutdown. + * + * @ticket 65461 + */ + public function test_post_list_failed_token_update_is_retried() { + $post_id = self::factory()->post->create(); + _wp_finalize_post_list_table_last_changed(); + $last_changed = _wp_get_post_list_table_last_changed( 'post' ); + $filter = static function ( $value, $old_value ) { + return $old_value; + }; + + add_filter( 'pre_update_option__wp_post_list_table_last_changed_post', $filter, 10, 2 ); + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Updated title', + ) + ); + remove_filter( 'pre_update_option__wp_post_list_table_last_changed_post', $filter ); + $this->assertSame( $last_changed, _wp_get_post_list_table_last_changed( 'post' ) ); + + _wp_finalize_post_list_table_last_changed(); + $this->assertNotSame( $last_changed, _wp_get_post_list_table_last_changed( 'post' ) ); + } + /** * Data provider for test_wp_heartbeat_set_suspension(). *