From 9b3a557121ad571b78dfa3e85174295773ccbc4a Mon Sep 17 00:00:00 2001
From: Enrico Battocchi
Date: Fri, 27 Mar 2026 17:59:09 +0100
Subject: [PATCH 1/6] feat(editor): dynamically hide R&R button when a copy
already exists
Register _dp_has_rewrite_republish_copy post meta for the REST API and
read it reactively via the core store's getEntityRecord. Since the R&R
copy is created via a server-side admin action (not through the editor),
the meta change does not enter the RTC CRDT document. Periodic cache
invalidation (every 15s) ensures the editor refetches the entity record
and picks up the change.
A snackbar notification informs the user when another collaborator has
created an R&R copy, explaining why the button disappeared.
Ref: Yoast/reserved-tasks#1127
Co-Authored-By: Claude Opus 4.6 (1M context)
---
js/src/duplicate-post-edit-script.js | 57 ++++++++++++++++++++++++++--
src/ui/block-editor.php | 30 +++++++++++++++
tests/Unit/UI/Block_Editor_Test.php | 42 ++++++++++++++++++++
3 files changed, 125 insertions(+), 4 deletions(-)
diff --git a/js/src/duplicate-post-edit-script.js b/js/src/duplicate-post-edit-script.js
index 3587ef8d0..70053b61a 100644
--- a/js/src/duplicate-post-edit-script.js
+++ b/js/src/duplicate-post-edit-script.js
@@ -1,12 +1,12 @@
/* global duplicatePost, duplicatePostNotices */
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { registerPlugin } from "@wordpress/plugins";
import { PluginDocumentSettingPanel, PluginPostStatusInfo } from "@wordpress/editor";
import { Fragment } from "@wordpress/element";
import { Button, ExternalLink, Modal } from '@wordpress/components';
import { __ } from "@wordpress/i18n";
-import { select, subscribe, dispatch } from "@wordpress/data";
+import { select, subscribe, dispatch, useSelect } from "@wordpress/data";
import apiFetch from "@wordpress/api-fetch";
import { redirectOnSaveCompletion } from "./duplicate-post-functions";
@@ -123,13 +123,62 @@ function DuplicatePostPanel() {
*
* @returns {JSX.Element|null} The rendered component or null.
*/
+/**
+ * Interval in milliseconds for polling the R&R copy meta.
+ *
+ * The R&R copy is created via a server-side admin action, so the meta change
+ * does not enter the RTC CRDT document. Periodic cache invalidation ensures
+ * the editor refetches the entity record and picks up the change.
+ *
+ * @type {number}
+ */
+const RR_COPY_POLL_INTERVAL = 15000;
+
function DuplicatePostRender() {
// Don't try to render anything if there is no store.
if ( ! select( 'core/editor' ) || ! ( wp.editor && wp.editor.PluginPostStatusInfo ) ) {
return null;
}
- const currentPostStatus = select( 'core/editor' ).getEditedPostAttribute( 'status' );
+ const { currentPostStatus, hasRewriteAndRepublishCopy, postType, postId } = useSelect( ( sel ) => {
+ const editor = sel( 'core/editor' );
+ const type = editor.getCurrentPostType();
+ const id = editor.getCurrentPostId();
+ const record = sel( 'core' ).getEntityRecord( 'postType', type, id );
+
+ return {
+ currentPostStatus: editor.getEditedPostAttribute( 'status' ),
+ hasRewriteAndRepublishCopy: !! record?.meta?._dp_has_rewrite_republish_copy,
+ postType: type,
+ postId: id,
+ };
+ }, [] );
+
+ const prevHasRRCopy = useRef( hasRewriteAndRepublishCopy );
+
+ // Periodically invalidate the entity record cache to detect server-side
+ // meta changes (e.g. when another user creates an R&R copy via admin action).
+ useEffect( () => {
+ if ( hasRewriteAndRepublishCopy || ! postType || ! postId ) {
+ return;
+ }
+ const interval = setInterval( () => {
+ dispatch( 'core' ).invalidateResolution( 'getEntityRecord', [ 'postType', postType, postId ] );
+ }, RR_COPY_POLL_INTERVAL );
+ return () => clearInterval( interval );
+ }, [ hasRewriteAndRepublishCopy, postType, postId ] );
+
+ // Notify the user when another collaborator creates an R&R copy.
+ useEffect( () => {
+ if ( hasRewriteAndRepublishCopy && ! prevHasRRCopy.current ) {
+ dispatch( 'core/notices' ).createNotice(
+ 'info',
+ __( 'Another user has started a Rewrite & Republish for this post.', 'duplicate-post' ),
+ { isDismissible: true, type: 'snackbar' },
+ );
+ }
+ prevHasRRCopy.current = hasRewriteAndRepublishCopy;
+ }, [ hasRewriteAndRepublishCopy ] );
return (
@@ -146,7 +195,7 @@ function DuplicatePostRender() {
}
- { ( currentPostStatus === 'publish' && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
+ { ( currentPostStatus === 'publish' && ! hasRewriteAndRepublishCopy && duplicatePost.rewriteAndRepublishLink !== '' && duplicatePost.showLinks.rewrite_republish === '1' ) &&
';
}
+
+ if ( ! empty( $_REQUEST['dpcollabredirected'] ) ) {
+ echo ''
+ . \esc_html( $this->get_collab_redirect_notice_text() )
+ . '
';
+ }
}
/**
@@ -104,5 +123,19 @@ public function add_block_editor_notice() {
'before',
);
}
+
+ if ( ! empty( $_REQUEST['dpcollabredirected'] ) ) {
+ $notice = [
+ 'text' => $this->get_collab_redirect_notice_text(),
+ 'status' => 'info',
+ 'isDismissible' => true,
+ ];
+
+ \wp_add_inline_script(
+ 'duplicate_post_edit_script',
+ 'duplicatePostNotices.collab_redirect_notice = ' . \wp_json_encode( $notice ) . ';',
+ 'before',
+ );
+ }
}
}
diff --git a/tests/Unit/Watchers/Republished_Post_Watcher_Test.php b/tests/Unit/Watchers/Republished_Post_Watcher_Test.php
index 08d3517e5..d35bbf755 100644
--- a/tests/Unit/Watchers/Republished_Post_Watcher_Test.php
+++ b/tests/Unit/Watchers/Republished_Post_Watcher_Test.php
@@ -241,6 +241,7 @@ public function test_add_removable_query_args() {
'dprepublished',
'dpcopy',
'dpnonce',
+ 'dpcollabredirected',
],
$this->instance->add_removable_query_args( $array ),
);
From bfb3622256cffd6b4d172163ed6fcbe37674a267 Mon Sep 17 00:00:00 2001
From: Enrico Battocchi
Date: Fri, 27 Mar 2026 21:10:55 +0100
Subject: [PATCH 3/6] fix(duplicator): prevent race condition in R&R copy
creation
Claim the slot on the original post using add_post_meta() with
$unique = true before creating the copy. This returns false if the
meta key already exists, preventing duplicate copies when two concurrent
requests both pass the permission check before either sets the meta.
If wp_insert_post() fails, the claim is rolled back by deleting the
meta.
Also fixes Block_Editor_Test to mock get_post_type_object() for the
restBase addition to the localized JS object.
Ref: Yoast/reserved-tasks#1127
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/post-duplicator.php | 29 ++++++++++++++++++++++-------
tests/Unit/UI/Block_Editor_Test.php | 18 ++++++++++++++++++
2 files changed, 40 insertions(+), 7 deletions(-)
diff --git a/src/post-duplicator.php b/src/post-duplicator.php
index e23a61e1e..06a223e36 100644
--- a/src/post-duplicator.php
+++ b/src/post-duplicator.php
@@ -149,6 +149,17 @@ public function set_modified( $data ) {
* @return int|WP_Error The copy ID, or a WP_Error object on failure.
*/
public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
+ // Claim the slot on the original before creating the copy to prevent
+ // a race condition where two concurrent requests both pass the
+ // permission check before either has set the meta.
+ $claimed = \add_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', 'pending', true );
+ if ( ! $claimed ) {
+ return new \WP_Error(
+ 'duplicate_post_already_has_copy',
+ \__( 'A Rewrite & Republish copy already exists for this post.', 'duplicate-post' ),
+ );
+ }
+
$options = [
'copy_title' => true,
'copy_date' => true,
@@ -164,15 +175,19 @@ public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
$new_post_id = $this->create_duplicate( $post, $options );
- if ( ! \is_wp_error( $new_post_id ) ) {
- $this->copy_post_taxonomies( $new_post_id, $post, $options );
- $this->copy_post_meta_info( $new_post_id, $post, $options );
-
- \update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
- \update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
- \update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );
+ if ( \is_wp_error( $new_post_id ) ) {
+ // Roll back the claim if copy creation failed.
+ \delete_post_meta( $post->ID, '_dp_has_rewrite_republish_copy' );
+ return $new_post_id;
}
+ $this->copy_post_taxonomies( $new_post_id, $post, $options );
+ $this->copy_post_meta_info( $new_post_id, $post, $options );
+
+ \update_post_meta( $new_post_id, '_dp_is_rewrite_republish_copy', 1 );
+ \update_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', $new_post_id );
+ \update_post_meta( $new_post_id, '_dp_creation_date_gmt', \current_time( 'mysql', 1 ) );
+
return $new_post_id;
}
diff --git a/tests/Unit/UI/Block_Editor_Test.php b/tests/Unit/UI/Block_Editor_Test.php
index 90e73e802..4c064282e 100644
--- a/tests/Unit/UI/Block_Editor_Test.php
+++ b/tests/Unit/UI/Block_Editor_Test.php
@@ -310,6 +310,7 @@ public function test_enqueue_block_editor_scripts() {
$utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' );
$post = Mockery::mock( WP_Post::class );
$post->ID = 123;
+ $post->post_type = 'post';
$new_draft_link = 'http://fakeu.rl/new_draft';
$rewrite_and_republish_link = 'http://fakeu.rl/rewrite_and_republish';
$rewriting = 0;
@@ -374,8 +375,16 @@ public function test_enqueue_block_editor_scripts() {
->with( $post )
->andReturnNull();
+ $post_type_object = Mockery::mock( 'WP_Post_Type' );
+ $post_type_object->rest_base = 'posts';
+
+ Monkey\Functions\expect( '\get_post_type_object' )
+ ->with( 'post' )
+ ->andReturn( $post_type_object );
+
$edit_js_object = [
'postId' => 123,
+ 'restBase' => 'posts',
'newDraftLink' => $new_draft_link,
'rewriteAndRepublishLink' => $rewrite_and_republish_link,
'showLinks' => $show_links,
@@ -414,6 +423,7 @@ public function test_get_enqueue_block_editor_scripts_rewrite_and_republish() {
$utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' );
$post = Mockery::mock( WP_Post::class );
$post->ID = 123;
+ $post->post_type = 'post';
$new_draft_link = 'http://fakeu.rl/new_draft';
$rewrite_and_republish_link = 'http://fakeu.rl/rewrite_and_republish';
$rewriting = 1;
@@ -475,8 +485,16 @@ public function test_get_enqueue_block_editor_scripts_rewrite_and_republish() {
->with( $post )
->andReturnNull();
+ $post_type_object = Mockery::mock( 'WP_Post_Type' );
+ $post_type_object->rest_base = 'posts';
+
+ Monkey\Functions\expect( '\get_post_type_object' )
+ ->with( 'post' )
+ ->andReturn( $post_type_object );
+
$edit_js_object = [
'postId' => 123,
+ 'restBase' => 'posts',
'newDraftLink' => $new_draft_link,
'rewriteAndRepublishLink' => $rewrite_and_republish_link,
'showLinks' => $show_links,
From f2b51f8c579e5acb982b07e39923668726ec206f Mon Sep 17 00:00:00 2001
From: Enrico Battocchi
Date: Fri, 27 Mar 2026 21:35:20 +0100
Subject: [PATCH 4/6] Fix CS
---
src/post-duplicator.php | 2 +-
src/ui/block-editor.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/post-duplicator.php b/src/post-duplicator.php
index 06a223e36..c20d1a02b 100644
--- a/src/post-duplicator.php
+++ b/src/post-duplicator.php
@@ -154,7 +154,7 @@ public function create_duplicate_for_rewrite_and_republish( WP_Post $post ) {
// permission check before either has set the meta.
$claimed = \add_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', 'pending', true );
if ( ! $claimed ) {
- return new \WP_Error(
+ return new WP_Error(
'duplicate_post_already_has_copy',
\__( 'A Rewrite & Republish copy already exists for this post.', 'duplicate-post' ),
);
diff --git a/src/ui/block-editor.php b/src/ui/block-editor.php
index d91e123b7..31a10c896 100644
--- a/src/ui/block-editor.php
+++ b/src/ui/block-editor.php
@@ -275,7 +275,7 @@ protected function generate_js_object( WP_Post $post ) {
return [
'postId' => $post->ID,
- 'restBase' => $post_type_object ? $post_type_object->rest_base : '',
+ 'restBase' => ( $post_type_object ) ? $post_type_object->rest_base : '',
'newDraftLink' => $this->get_new_draft_permalink(),
'rewriteAndRepublishLink' => $this->get_rewrite_republish_permalink(),
'showLinks' => Utils::get_option( 'duplicate_post_show_link' ),
From 04b39d83b010c5f4c4b6def7c05d5ae59657d952 Mon Sep 17 00:00:00 2001
From: Enrico Battocchi
Date: Fri, 27 Mar 2026 21:50:46 +0100
Subject: [PATCH 5/6] fix: address Copilot review feedback and improve test
coverage
- Use per-post auth_callback for register_post_meta (edit_post cap
instead of generic edit_posts).
- Fall back to post type name when rest_base is empty, matching the
pattern used by WP core REST controllers.
- Only redirect collaborator on confirmed deletion (404/410/403), not
on transient network errors.
- Improve register_post_meta unit test to verify config values.
- Add WP integration tests for R&R race condition: slot claiming,
duplicate blocking, and rollback on failure.
Ref: Yoast/reserved-tasks#1127
Co-Authored-By: Claude Opus 4.6 (1M context)
---
js/src/duplicate-post-edit-script.js | 9 ++--
src/ui/block-editor.php | 10 ++--
tests/Unit/UI/Block_Editor_Test.php | 22 +++++----
tests/WP/Post_Duplicator_Test.php | 68 ++++++++++++++++++++++++++++
4 files changed, 93 insertions(+), 16 deletions(-)
diff --git a/js/src/duplicate-post-edit-script.js b/js/src/duplicate-post-edit-script.js
index 7df033ea8..8e1c00518 100644
--- a/js/src/duplicate-post-edit-script.js
+++ b/js/src/duplicate-post-edit-script.js
@@ -291,9 +291,12 @@ class DuplicatePost {
if ( response.status === 'dp-rewrite-republish' || response.status === 'trash' ) {
redirectToOriginal();
}
- } catch {
- // 404 or 403 — the copy was deleted.
- redirectToOriginal();
+ } catch ( error ) {
+ // Only redirect on confirmed deletion/permission errors, not transient network failures.
+ const status = error?.data?.status ?? error?.status;
+ if ( status === 404 || status === 410 || status === 403 ) {
+ redirectToOriginal();
+ }
}
};
diff --git a/src/ui/block-editor.php b/src/ui/block-editor.php
index 31a10c896..44dd64ff8 100644
--- a/src/ui/block-editor.php
+++ b/src/ui/block-editor.php
@@ -80,8 +80,8 @@ public function register_post_meta() {
'single' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
- 'auth_callback' => static function () {
- return \current_user_can( 'edit_posts' );
+ 'auth_callback' => static function ( $allowed, $meta_key, $post_id ) {
+ return \current_user_can( 'edit_post', $post_id );
},
],
);
@@ -272,10 +272,14 @@ protected function generate_js_object( WP_Post $post ) {
}
$post_type_object = \get_post_type_object( $post->post_type );
+ $rest_base = '';
+ if ( $post_type_object ) {
+ $rest_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name;
+ }
return [
'postId' => $post->ID,
- 'restBase' => ( $post_type_object ) ? $post_type_object->rest_base : '',
+ 'restBase' => $rest_base,
'newDraftLink' => $this->get_new_draft_permalink(),
'rewriteAndRepublishLink' => $this->get_rewrite_republish_permalink(),
'showLinks' => Utils::get_option( 'duplicate_post_show_link' ),
diff --git a/tests/Unit/UI/Block_Editor_Test.php b/tests/Unit/UI/Block_Editor_Test.php
index 4c064282e..21e266bfd 100644
--- a/tests/Unit/UI/Block_Editor_Test.php
+++ b/tests/Unit/UI/Block_Editor_Test.php
@@ -174,21 +174,23 @@ public function test_register_post_meta() {
->expects( 'get_enabled_post_types' )
->andReturn( [ 'post', 'page' ] );
+ $expected_args = Mockery::on(
+ static function ( $args ) {
+ return $args['show_in_rest'] === true
+ && $args['single'] === true
+ && $args['type'] === 'string'
+ && $args['sanitize_callback'] === 'sanitize_text_field'
+ && \is_callable( $args['auth_callback'] );
+ },
+ );
+
Monkey\Functions\expect( '\register_post_meta' )
->once()
- ->with(
- 'post',
- '_dp_has_rewrite_republish_copy',
- Mockery::type( 'array' ),
- );
+ ->with( 'post', '_dp_has_rewrite_republish_copy', $expected_args );
Monkey\Functions\expect( '\register_post_meta' )
->once()
- ->with(
- 'page',
- '_dp_has_rewrite_republish_copy',
- Mockery::type( 'array' ),
- );
+ ->with( 'page', '_dp_has_rewrite_republish_copy', $expected_args );
$this->instance->register_post_meta();
}
diff --git a/tests/WP/Post_Duplicator_Test.php b/tests/WP/Post_Duplicator_Test.php
index bb7414d8e..8ec37556b 100644
--- a/tests/WP/Post_Duplicator_Test.php
+++ b/tests/WP/Post_Duplicator_Test.php
@@ -50,4 +50,72 @@ public function test_create_duplicate() {
$this->assertIsInt( $id );
}
+
+ /**
+ * Tests that creating an R&R copy claims the slot on the original post.
+ *
+ * @covers ::create_duplicate_for_rewrite_and_republish
+ *
+ * @return void
+ */
+ public function test_create_duplicate_for_rewrite_and_republish_claims_slot() {
+ $post = $this->factory->post->create_and_get( [ 'post_status' => 'publish' ] );
+
+ $copy_id = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ $this->assertIsInt( $copy_id );
+ $this->assertSame( (string) $copy_id, \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', true ) );
+ $this->assertSame( 1, (int) \get_post_meta( $copy_id, '_dp_is_rewrite_republish_copy', true ) );
+ }
+
+ /**
+ * Tests that creating an R&R copy fails when the original already has one.
+ *
+ * @covers ::create_duplicate_for_rewrite_and_republish
+ *
+ * @return void
+ */
+ public function test_create_duplicate_for_rewrite_and_republish_blocks_duplicate() {
+ $post = $this->factory->post->create_and_get( [ 'post_status' => 'publish' ] );
+
+ $first_copy_id = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ $this->assertIsInt( $first_copy_id );
+
+ $second_result = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ $this->assertInstanceOf( \WP_Error::class, $second_result );
+ $this->assertSame( 'duplicate_post_already_has_copy', $second_result->get_error_code() );
+
+ // Original should still reference the first copy only.
+ $meta_values = \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy' );
+ $this->assertCount( 1, $meta_values );
+ $this->assertSame( (string) $first_copy_id, $meta_values[0] );
+ }
+
+ /**
+ * Tests that the claim is rolled back when copy creation fails.
+ *
+ * @covers ::create_duplicate_for_rewrite_and_republish
+ *
+ * @return void
+ */
+ public function test_create_duplicate_for_rewrite_and_republish_rolls_back_on_failure() {
+ $post = $this->factory->post->create_and_get( [ 'post_status' => 'publish' ] );
+
+ // Force wp_insert_post to fail by filtering the post data.
+ \add_filter(
+ 'wp_insert_post_empty_content',
+ '__return_true',
+ );
+
+ $result = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
+
+ \remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+
+ // The claim should have been rolled back.
+ $this->assertSame( '', \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', true ) );
+ }
}
From 748f588e85a3aaa6dab181410e8869f0739aac68 Mon Sep 17 00:00:00 2001
From: Enrico Battocchi
Date: Fri, 27 Mar 2026 21:56:24 +0100
Subject: [PATCH 6/6] Fix CS
---
tests/WP/Post_Duplicator_Test.php | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/tests/WP/Post_Duplicator_Test.php b/tests/WP/Post_Duplicator_Test.php
index 8ec37556b..de900fa07 100644
--- a/tests/WP/Post_Duplicator_Test.php
+++ b/tests/WP/Post_Duplicator_Test.php
@@ -2,6 +2,7 @@
namespace Yoast\WP\Duplicate_Post\Tests\WP;
+use WP_Error;
use Yoast\WP\Duplicate_Post\Post_Duplicator;
use Yoast\WPTestUtils\WPIntegration\TestCase;
@@ -84,11 +85,11 @@ public function test_create_duplicate_for_rewrite_and_republish_blocks_duplicate
$second_result = $this->instance->create_duplicate_for_rewrite_and_republish( $post );
- $this->assertInstanceOf( \WP_Error::class, $second_result );
+ $this->assertInstanceOf( WP_Error::class, $second_result );
$this->assertSame( 'duplicate_post_already_has_copy', $second_result->get_error_code() );
// Original should still reference the first copy only.
- $meta_values = \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy' );
+ $meta_values = \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', false );
$this->assertCount( 1, $meta_values );
$this->assertSame( (string) $first_copy_id, $meta_values[0] );
}
@@ -113,7 +114,7 @@ public function test_create_duplicate_for_rewrite_and_republish_rolls_back_on_fa
\remove_filter( 'wp_insert_post_empty_content', '__return_true' );
- $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertInstanceOf( WP_Error::class, $result );
// The claim should have been rolled back.
$this->assertSame( '', \get_post_meta( $post->ID, '_dp_has_rewrite_republish_copy', true ) );