From 659e7b54f767deb65ae877af35d5b577a7f29010 Mon Sep 17 00:00:00 2001 From: Dan Luu Date: Thu, 30 Apr 2026 17:51:56 -0700 Subject: [PATCH 1/5] RTC: Backport fix to prevent autosaves from losing content. This is a companion change to wordpress/gutenberg#77865. --- .../class-wp-rest-autosaves-controller.php | 16 +++- .../collaboration/restAutosavesController.php | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/tests/collaboration/restAutosavesController.php diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index f0cec04f191f8..2ba1537fdd338 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -229,8 +229,9 @@ public function create_item( $request ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } - $post_lock = wp_check_post_lock( $post->ID ); - $is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status; + $post_lock = wp_check_post_lock( $post->ID ); + $is_auto_draft = 'auto-draft' === $post->post_status; + $is_draft = 'draft' === $post->post_status || $is_auto_draft; /* * In the context of real-time collaboration, all peers are effectively @@ -256,10 +257,19 @@ public function create_item( $request ) { */ $is_collaboration_enabled = wp_is_collaboration_enabled(); - if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) { + if ( + $is_draft && + (int) $post->post_author === $user_id && + ! $post_lock && + ( ! $is_collaboration_enabled || $is_auto_draft ) + ) { /* * Draft posts for the same author: autosaving updates the post and does not create a revision. * Convert the post object to an array and add slashes, wp_update_post() expects escaped array. + * + * Auto-drafts must still be promoted to drafts when collaboration is enabled so that a new post + * survives URL loss and appears in Drafts. Regular draft autosaves remain revisions under + * collaboration to keep the saved post from diverging from the persisted CRDT document. */ $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true ); } else { diff --git a/tests/phpunit/tests/collaboration/restAutosavesController.php b/tests/phpunit/tests/collaboration/restAutosavesController.php new file mode 100644 index 0000000000000..298107e55af9b --- /dev/null +++ b/tests/phpunit/tests/collaboration/restAutosavesController.php @@ -0,0 +1,94 @@ +user->create( array( 'role' => 'author' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$author_id ); + delete_option( 'wp_collaboration_enabled' ); + } + + public function set_up() { + parent::set_up(); + wp_set_current_user( self::$author_id ); + } + + /** + * Creates an empty auto-draft post. + * + * @return int Post ID. + */ + private function create_auto_draft(): int { + return self::factory()->post->create( + array( + 'post_author' => self::$author_id, + 'post_content' => '', + 'post_status' => 'auto-draft', + 'post_title' => 'Auto Draft', + 'post_type' => 'post', + ) + ); + } + + /** + * Dispatches an autosave request for a post. + * + * @param int $post_id Post ID. + * @param string $title Autosaved post title. + * @param string $content Autosaved post content. + * @return WP_REST_Response Autosave response. + */ + private function dispatch_autosave( int $post_id, string $title, string $content ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', "/wp/v2/posts/{$post_id}/autosaves" ); + $request->set_param( 'title', $title ); + $request->set_param( 'content', $content ); + $request->set_param( 'status', 'draft' ); + + return rest_get_server()->dispatch( $request ); + } + + public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_disabled() { + update_option( 'wp_collaboration_enabled', 0 ); + + $post_id = $this->create_auto_draft(); + $title = 'No RTC autosaved title'; + $content = '

No RTC autosaved content

'; + + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $title, $post->post_title ); + $this->assertSame( $content, $post->post_content ); + } + + public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() { + update_option( 'wp_collaboration_enabled', 1 ); + + $post_id = $this->create_auto_draft(); + $title = 'RTC autosaved title'; + $content = '

RTC autosaved content

'; + + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $title, $post->post_title ); + $this->assertSame( $content, $post->post_content ); + } +} From 8763d877a64cf3fba1a4fe05b67beec88afe8aa9 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 4 May 2026 23:23:17 -0700 Subject: [PATCH 2/5] Track updates to upstream PR in Gutenberg --- .../class-wp-rest-autosaves-controller.php | 54 +++++++------------ 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 2ba1537fdd338..ae44433cb4bb8 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -229,51 +229,35 @@ public function create_item( $request ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } - $post_lock = wp_check_post_lock( $post->ID ); - $is_auto_draft = 'auto-draft' === $post->post_status; - $is_draft = 'draft' === $post->post_status || $is_auto_draft; + $post_lock_is_active = wp_check_post_lock( $post->ID ); + $is_auto_draft = 'auto-draft' === $post->post_status; + $is_draft = 'draft' === $post->post_status || $is_auto_draft; + $is_collaboration_enabled = wp_is_collaboration_enabled(); /* - * In the context of real-time collaboration, all peers are effectively - * authors and we don't want to vary behavior based on whether they are the - * original author. Always target an autosave revision. - * - * This avoids the following issue when real-time collaboration is enabled: - * - * - Autosaves from the original author (if they have the post lock) will - * target the saved post. - * - * - Autosaves from other users are applied to a post revision. - * - * - If any user reloads a post, they load changes from the author's autosave. + * When a post is still in draft form, updates from the author can directly update the post. + * Other autosaves must be stored as per-user autosave revisions. * - * - The saved post has now diverged from the persisted CRDT document. The - * content (and/or title or excerpt) are now "ahead" of the persisted CRDT - * document. + * When RTC is active, however, regular draft autosaves must not update the parent post directly. + * Since all peers are sharing a persisted editing state (a shared CRDT), it’s important that + * they all store updates in a revision. If edits were applied to the post, then upon the next + * editor reload, it would appear as though the post had been updated externally, and those same + * changes would be re-applied to the CRDT, duplicating the edits. * - * - When the persisted CRDT document is loaded, a diff is computed against - * the saved post. This diff is then applied to the in-memory CRDT - * document, which can lead to duplicate inserts or deletions. + * The one caveat for RTC is that the first peer to store an edit must promote an auto-draft + * into a real draft post. If this doesn’t happen then the peers may continue to make edits + * but the draft will be lost, as auto-drafts are not listed in post views. */ - $is_collaboration_enabled = wp_is_collaboration_enabled(); - - if ( + $should_update_parent_draft_post = ( $is_draft && (int) $post->post_author === $user_id && - ! $post_lock && + ! $post_lock_is_active && ( ! $is_collaboration_enabled || $is_auto_draft ) - ) { - /* - * Draft posts for the same author: autosaving updates the post and does not create a revision. - * Convert the post object to an array and add slashes, wp_update_post() expects escaped array. - * - * Auto-drafts must still be promoted to drafts when collaboration is enabled so that a new post - * survives URL loss and appears in Drafts. Regular draft autosaves remain revisions under - * collaboration to keep the saved post from diverging from the persisted CRDT document. - */ + ); + + if ( $should_update_parent_draft_post ) { $autosave_id = wp_update_post( wp_slash( (array) $prepared_post ), true ); } else { - // Non-draft posts: create or update the post autosave. Pass the meta data. $autosave_id = $this->create_post_autosave( (array) $prepared_post, (array) $request->get_param( 'meta' ) ); } From 0db23e42c16031ff8e773f264e85c6accdd40e10 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 5 May 2026 11:49:59 -0700 Subject: [PATCH 3/5] Incorporate updates from upstream patch --- .../class-wp-rest-autosaves-controller.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index ae44433cb4bb8..f5905e32ea24d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -248,11 +248,20 @@ public function create_item( $request ) { * into a real draft post. If this doesn’t happen then the peers may continue to make edits * but the draft will be lost, as auto-drafts are not listed in post views. */ - $should_update_parent_draft_post = ( + $can_update_author_draft_post = ( $is_draft && (int) $post->post_author === $user_id && - ! $post_lock_is_active && - ( ! $is_collaboration_enabled || $is_auto_draft ) + ! $is_collaboration_enabled + ); + $can_promote_auto_draft_post = ( + $is_auto_draft && + $is_collaboration_enabled && + current_user_can( 'edit_post', $post->ID ) + ); + + $should_update_parent_draft_post = ( + $can_promote_auto_draft_post || + ( ! $post_lock_is_active && $can_update_author_draft_post ) ); if ( $should_update_parent_draft_post ) { From 5fb3c351db7ec4d49c26c9236b3a60eb284fffad Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 6 May 2026 13:51:07 -0700 Subject: [PATCH 4/5] Updates from final merged PR in Gutenberg --- .../class-wp-rest-autosaves-controller.php | 3 +- .../collaboration/restAutosavesController.php | 64 ++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index f5905e32ea24d..97135e03da2da 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -253,7 +253,8 @@ public function create_item( $request ) { (int) $post->post_author === $user_id && ! $is_collaboration_enabled ); - $can_promote_auto_draft_post = ( + + $can_promote_auto_draft_post = ( $is_auto_draft && $is_collaboration_enabled && current_user_can( 'edit_post', $post->ID ) diff --git a/tests/phpunit/tests/collaboration/restAutosavesController.php b/tests/phpunit/tests/collaboration/restAutosavesController.php index 298107e55af9b..9bb3bb9361dac 100644 --- a/tests/phpunit/tests/collaboration/restAutosavesController.php +++ b/tests/phpunit/tests/collaboration/restAutosavesController.php @@ -2,7 +2,7 @@ /** * Tests for the collaboration autosaves REST controller override. * - * @package gutenberg + * @package WordPress * @subpackage Collaboration * * @group collaboration @@ -11,13 +11,16 @@ class Tests_Collaboration_RestAutosavesController extends WP_UnitTestCase { protected static int $author_id; + protected static int $editor_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$author_id = $factory->user->create( array( 'role' => 'author' ) ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); } public static function wpTearDownAfterClass() { self::delete_user( self::$author_id ); + self::delete_user( self::$editor_id ); delete_option( 'wp_collaboration_enabled' ); } @@ -43,6 +46,25 @@ private function create_auto_draft(): int { ); } + /** + * Creates a draft post. + * + * @param string $title Post title. + * @param string $content Post content. + * @return int Post ID. + */ + private function create_draft( string $title, string $content ): int { + return self::factory()->post->create( + array( + 'post_author' => self::$author_id, + 'post_content' => $content, + 'post_status' => 'draft', + 'post_title' => $title, + 'post_type' => 'post', + ) + ); + } + /** * Dispatches an autosave request for a post. * @@ -91,4 +113,44 @@ public function test_auto_draft_autosave_promotes_parent_post_when_collaboration $this->assertSame( $title, $post->post_title ); $this->assertSame( $content, $post->post_content ); } + + public function test_collaborator_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() { + update_option( 'wp_collaboration_enabled', 1 ); + + $post_id = $this->create_auto_draft(); + $title = 'RTC collaborator autosaved title'; + $content = '

RTC collaborator autosaved content

'; + + wp_set_current_user( self::$editor_id ); + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $title, $post->post_title ); + $this->assertSame( $content, $post->post_content ); + } + + public function test_draft_autosave_creates_revision_when_collaboration_is_enabled() { + update_option( 'wp_collaboration_enabled', 1 ); + + $original_title = 'Original RTC draft title'; + $original_content = '

Original RTC draft content

'; + $post_id = $this->create_draft( $original_title, $original_content ); + $title = 'RTC draft autosaved title'; + $content = '

RTC draft autosaved content

'; + + $response = $this->dispatch_autosave( $post_id, $title, $content ); + + $this->assertSame( 200, $response->get_status() ); + $post = get_post( $post_id ); + $this->assertSame( 'draft', $post->post_status ); + $this->assertSame( $original_title, $post->post_title ); + $this->assertSame( $original_content, $post->post_content ); + + $autosave = wp_get_post_autosave( $post_id, self::$author_id ); + $this->assertInstanceOf( WP_Post::class, $autosave ); + $this->assertSame( $title, $autosave->post_title ); + $this->assertSame( $content, $autosave->post_content ); + } } From f2b1e55ce1ebe442bb77054d20fe5d1b4f8e00eb Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 6 May 2026 14:06:41 -0700 Subject: [PATCH 5/5] Add ticket links in tests --- .../tests/collaboration/restAutosavesController.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/phpunit/tests/collaboration/restAutosavesController.php b/tests/phpunit/tests/collaboration/restAutosavesController.php index 9bb3bb9361dac..05b98da610445 100644 --- a/tests/phpunit/tests/collaboration/restAutosavesController.php +++ b/tests/phpunit/tests/collaboration/restAutosavesController.php @@ -82,6 +82,9 @@ private function dispatch_autosave( int $post_id, string $title, string $content return rest_get_server()->dispatch( $request ); } + /** + * @ticket 65138 + */ public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_disabled() { update_option( 'wp_collaboration_enabled', 0 ); @@ -98,6 +101,9 @@ public function test_auto_draft_autosave_promotes_parent_post_when_collaboration $this->assertSame( $content, $post->post_content ); } + /** + * @ticket 65138 + */ public function test_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() { update_option( 'wp_collaboration_enabled', 1 ); @@ -114,6 +120,9 @@ public function test_auto_draft_autosave_promotes_parent_post_when_collaboration $this->assertSame( $content, $post->post_content ); } + /** + * @ticket 65138 + */ public function test_collaborator_auto_draft_autosave_promotes_parent_post_when_collaboration_is_enabled() { update_option( 'wp_collaboration_enabled', 1 ); @@ -131,6 +140,9 @@ public function test_collaborator_auto_draft_autosave_promotes_parent_post_when_ $this->assertSame( $content, $post->post_content ); } + /** + * @ticket 65138 + */ public function test_draft_autosave_creates_revision_when_collaboration_is_enabled() { update_option( 'wp_collaboration_enabled', 1 );