Commit 5ec0df0e5c0 for woocommerce
commit 5ec0df0e5c08ac9b8ee089f7c3427331e721963e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Sep 9 15:35:38 2026 +0300
Fix stale attribute taxonomy state after deletion (#68212)
* fix: Clear runtime registries when deleting an attribute
wc_delete_attribute() removed the database row, the terms, and the
caches, but left the attribute's taxonomy in WordPress' $wp_taxonomies
and its entry in WooCommerce's $wc_product_attributes. For the rest of
that request both registries still described an attribute that no
longer existed.
wc_create_attribute() decides whether a slug is free by asking
taxonomy_exists(), so recreating the same slug in the same request
failed with invalid_product_attribute_slug_already_exists. The next
request was fine, because both registries are rebuilt from the database
on init. That narrow window is what makes the bug easy to miss: it hits
import and sync routines that reset a global attribute, WP-CLI commands
that do the same, and WooCommerce's own single-process PHPUnit suite,
where several tests already hand-roll this cleanup.
Unregister the taxonomy and drop the runtime entry once the deletion
succeeds. Both registries have to be cleared, because
wc_is_attribute_taxonomy() reads both.
Deleting the terms first queues term counts whenever the caller has
deferred counting, and WordPress resolves those by taxonomy name when
the queue is later flushed. Flush the queue while the taxonomy still
exists; otherwise get_taxonomy() returns false and the flush warns.
Refs #38919
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf: Skip the deferred count flush when no terms were deleted
Deleting an attribute inside a deferred term-counting window flushed
WordPress' pending count queue so the queued counts would not resolve a
taxonomy that was about to be unregistered. That flush ran on every
deletion, including ones that queued nothing.
WordPress drains its entire shared queue in one go, so a deletion with
no terms of its own still forced a recount of whatever unrelated
taxonomies the caller had pending. An import routine resetting an
attribute it just created — no terms, nothing queued — paid for a full
recount pass in the middle of someone else's batch.
Only flush when terms were actually deleted, since that is the only way
this deletion puts anything in the queue. When terms were deleted the
full drain is still unavoidable: $_deferred is a function-static inside
wp_update_term_count() with no per-taxonomy accessor.
Refs #38919
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Flush deferred term counts whenever counting is deferred
wc_delete_attribute() flushes WordPress' deferred term-count queue
before unregistering the taxonomy, because queued counts resolve the
taxonomy by name and warn once it is gone. Commit c4628fb195 gated that
flush on whether this call had deleted a term itself, to spare callers
a whole-queue drain when a term-less attribute is deleted.
That gate tracked the wrong property. The queue can already hold this
taxonomy before wc_delete_attribute() runs: a caller that deletes or
detaches the attribute's terms inside its own deferral window, then
deletes the now term-less attribute, queued those counts itself. With
the gate, nothing was flushed, the taxonomy was unregistered, and the
caller's later drain failed with "Attempt to read property object_type
on bool" — the exact warning this cleanup exists to prevent. The gate
was also over-inclusive, since deleting a term with no product
relationships queues nothing at all.
Flush whenever counting is deferred. A needless drain costs bounded
recompute work and leaves the persisted counts identical, because
wp_update_term_count_now() recomputes from the database and anything
the caller changes afterwards is re-queued. A missed drain is a live
PHP warning. The deferral flag itself is never touched.
The regression test deletes the term on the caller's side before
deleting the attribute, then drains the queue under an error handler
so the assertion does not depend on PHPUnit converting warnings to
exceptions, which PHPUnit 10 stops doing. The existing flush test now
observes the count resolving through `edited_term_taxonomy` while the
taxonomy is still registered, instead of relying on the absence of a
warning. The test that pinned the skipped flush is removed, since it
asserted the behavior this commit reverts.
Refs #38919
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test: Assert the dual-registry consumer after attribute deletion
The reason wc_delete_attribute() clears both the WordPress taxonomy
registry and $wc_product_attributes is that
taxonomy_is_product_attribute() reads both and would answer
inconsistently if only one were cleared. The deletion tests checked
each registry separately but never called that consumer.
Assert it directly: false after a successful deletion and after a
callback pre-unregisters the taxonomy, true when the database deletion
fails. Also give the new test methods the blank-line spacing the rest
of the file uses.
Refs #38919
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-38919-unregister-deleted-attribute-taxonomy b/plugins/woocommerce/changelog/fix-38919-unregister-deleted-attribute-taxonomy
new file mode 100644
index 00000000000..575dc59ff4a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-38919-unregister-deleted-attribute-taxonomy
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Unregister a global product attribute's runtime taxonomy when the attribute is deleted so its slug can be reused in the same request.
diff --git a/plugins/woocommerce/includes/wc-attribute-functions.php b/plugins/woocommerce/includes/wc-attribute-functions.php
index 2eacac6fee4..678a6032bd4 100644
--- a/plugins/woocommerce/includes/wc-attribute-functions.php
+++ b/plugins/woocommerce/includes/wc-attribute-functions.php
@@ -736,7 +736,7 @@ function wc_update_attribute( $id, $args ) {
* @return bool
*/
function wc_delete_attribute( $id ) {
- global $wpdb;
+ global $wpdb, $wc_product_attributes;
$name = $wpdb->get_var(
$wpdb->prepare(
@@ -776,6 +776,20 @@ function wc_delete_attribute( $id ) {
* @param string $taxonomy Attribute taxonomy name.
*/
do_action( 'woocommerce_attribute_deleted', $id, $name, $taxonomy );
+
+ if ( taxonomy_exists( $taxonomy ) ) {
+ // When the caller defers term counting, any count queued for this taxonomy (by the
+ // deletions above or by the caller itself) resolves the taxonomy by name later, once
+ // it is gone. Flush while it still exists. WordPress drains its whole queue at once.
+ if ( wp_defer_term_counting() ) {
+ wp_update_term_count( array(), '', true );
+ }
+
+ unregister_taxonomy( $taxonomy );
+ }
+
+ unset( $wc_product_attributes[ $taxonomy ] );
+
wp_schedule_single_event( time(), 'woocommerce_flush_rewrite_rules' );
delete_transient( 'wc_attribute_taxonomies' );
WC_Cache_Helper::invalidate_cache_group( 'woocommerce-attributes' );
diff --git a/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
index 50944eaeeb8..5d41654d0b2 100644
--- a/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
@@ -224,6 +224,191 @@ class WC_Attribute_Functions_Test extends \WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should unregister a deleted attribute after its deletion hook and allow recreating the slug.
+ */
+ public function test_wc_delete_attribute_unregisters_runtime_entries_and_allows_recreation(): void {
+ global $wc_product_attributes;
+
+ $slug = $this->get_unique_attribute_slug( 'normal' );
+ $attribute = $this->create_registered_attribute( $slug );
+ $replacement_attribute_id = null;
+ $hook_taxonomy = null;
+ $hook_wc_attribute = null;
+ $deleted_callback = static function ( $id, $name, $taxonomy ) use ( &$hook_taxonomy, &$hook_wc_attribute, &$wc_product_attributes ): void {
+ $hook_taxonomy = get_taxonomy( $taxonomy );
+ $hook_wc_attribute = $wc_product_attributes[ $taxonomy ] ?? null;
+ };
+
+ add_action( 'woocommerce_attribute_deleted', $deleted_callback, 10, 3 );
+
+ try {
+ $this->assertTrue( wc_delete_attribute( $attribute['id'] ), 'The attribute should be deleted successfully.' );
+ $this->assertSame( $attribute['wp_taxonomy'], $hook_taxonomy, 'The deletion hook should observe the original WordPress taxonomy.' );
+ $this->assertSame( $attribute['wc_attribute'], $hook_wc_attribute, 'The deletion hook should observe the original WooCommerce attribute entry.' );
+ $this->assertFalse( taxonomy_exists( $attribute['taxonomy'] ), 'The deleted attribute taxonomy should be unregistered after the deletion hook.' );
+ $this->assertArrayNotHasKey( $attribute['taxonomy'], $wc_product_attributes, 'The deleted WooCommerce attribute entry should be removed after the deletion hook.' );
+ $this->assertFalse( taxonomy_is_product_attribute( $attribute['taxonomy'] ), 'The deleted taxonomy should no longer be reported as a product attribute.' );
+
+ $replacement_attribute_id = wc_create_attribute(
+ array(
+ 'name' => 'Recreated attribute',
+ 'slug' => $slug,
+ )
+ );
+
+ $this->assertIsInt( $replacement_attribute_id, 'The deleted attribute slug should be reusable in the same request.' );
+ } finally {
+ remove_action( 'woocommerce_attribute_deleted', $deleted_callback, 10 );
+ $this->clean_up_attribute_test_state(
+ array( $attribute['id'], $replacement_attribute_id ),
+ $attribute['taxonomy']
+ );
+ }
+ }
+
+ /**
+ * @testdox Should tolerate a deletion callback unregistering the original taxonomy first.
+ */
+ public function test_wc_delete_attribute_is_idempotent_when_taxonomy_is_pre_unregistered(): void {
+ global $wc_product_attributes;
+
+ $slug = $this->get_unique_attribute_slug( 'pre-unreg' );
+ $attribute = $this->create_registered_attribute( $slug );
+ $unregister_callback = static function ( $id, $name, $taxonomy ): void {
+ unregister_taxonomy( $taxonomy );
+ };
+
+ add_action( 'woocommerce_before_attribute_delete', $unregister_callback, 10, 3 );
+
+ try {
+ $this->assertTrue( wc_delete_attribute( $attribute['id'] ), 'The attribute should still be deleted after its taxonomy is unregistered by a callback.' );
+ $this->assertFalse( taxonomy_exists( $attribute['taxonomy'] ), 'The pre-unregistered taxonomy should remain absent.' );
+ $this->assertArrayNotHasKey( $attribute['taxonomy'], $wc_product_attributes, 'The original WooCommerce attribute entry should still be removed.' );
+ $this->assertFalse( taxonomy_is_product_attribute( $attribute['taxonomy'] ), 'The deleted taxonomy should no longer be reported as a product attribute.' );
+ } finally {
+ remove_action( 'woocommerce_before_attribute_delete', $unregister_callback, 10 );
+ $this->clean_up_attribute_test_state( array( $attribute['id'] ), $attribute['taxonomy'] );
+ }
+ }
+
+ /**
+ * @testdox Should leave the attribute taxonomy registered when the database deletion fails.
+ */
+ public function test_wc_delete_attribute_failed_delete_leaves_taxonomy_registered(): void {
+ global $wpdb, $wc_product_attributes;
+
+ $slug = $this->get_unique_attribute_slug( 'failure' );
+ $attribute = $this->create_registered_attribute( $slug );
+ $before_delete_callback = static function ( $id ) use ( $attribute, $wpdb ): void {
+ if ( $attribute['id'] !== $id ) {
+ return;
+ }
+
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->prefix}woocommerce_attribute_taxonomies WHERE attribute_id = %d",
+ $id
+ )
+ ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The test needs the outer deletion query to affect no rows.
+ };
+
+ add_action( 'woocommerce_before_attribute_delete', $before_delete_callback, 10, 3 );
+
+ try {
+ $this->assertFalse( wc_delete_attribute( $attribute['id'] ), 'The outer deletion should fail after the callback removes the database row.' );
+ $this->assertTrue( taxonomy_exists( $attribute['taxonomy'] ), 'A failed deletion should leave the taxonomy registered.' );
+ $this->assertArrayHasKey( $attribute['taxonomy'], $wc_product_attributes, 'A failed deletion should leave the WooCommerce attribute entry in place.' );
+ $this->assertTrue( taxonomy_is_product_attribute( $attribute['taxonomy'] ), 'A failed deletion should leave the taxonomy reported as a product attribute.' );
+ } finally {
+ remove_action( 'woocommerce_before_attribute_delete', $before_delete_callback, 10 );
+ $this->clean_up_attribute_test_state( array( $attribute['id'] ), $attribute['taxonomy'] );
+ }
+ }
+
+ /**
+ * @testdox Should flush deferred term counts before unregistering the taxonomy.
+ */
+ public function test_wc_delete_attribute_flushes_deferred_term_counts(): void {
+ $slug = $this->get_unique_attribute_slug( 'deferred' );
+ $attribute = $this->create_registered_attribute( $slug );
+
+ $term = wp_insert_term( 'Deferred term', $attribute['taxonomy'] );
+ $this->assertIsArray( $term, 'The fixture term should be created.' );
+
+ $product_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_title' => 'Deferred counting product',
+ 'post_status' => 'publish',
+ )
+ );
+ $this->assertIsInt( $product_id, 'The fixture product should be created.' );
+ wp_set_object_terms( $product_id, array( (int) $term['term_id'] ), $attribute['taxonomy'] );
+
+ // Record whether the queued count for this taxonomy resolves while it is still registered.
+ $counted_while_registered = false;
+ $count_callback = static function ( $tt_id, $taxonomy_name ) use ( $attribute, &$counted_while_registered ): void {
+ if ( $taxonomy_name === $attribute['taxonomy'] && taxonomy_exists( $taxonomy_name ) ) {
+ $counted_while_registered = true;
+ }
+ };
+ add_action( 'edited_term_taxonomy', $count_callback, 10, 2 );
+
+ wp_defer_term_counting( true );
+
+ try {
+ $this->assertTrue( wc_delete_attribute( $attribute['id'] ), 'The attribute should be deleted successfully.' );
+ $this->assertFalse( taxonomy_exists( $attribute['taxonomy'] ), 'The deleted attribute taxonomy should be unregistered.' );
+ $this->assertTrue( $counted_while_registered, 'The queued term count should be resolved before the taxonomy is unregistered.' );
+
+ $this->assertSame( array(), $this->drain_deferred_term_counts(), 'Draining the queue must not resolve the unregistered taxonomy.' );
+ } finally {
+ remove_action( 'edited_term_taxonomy', $count_callback, 10 );
+ $this->drain_deferred_term_counts();
+ wp_delete_post( $product_id, true );
+ $this->clean_up_attribute_test_state( array( $attribute['id'] ), $attribute['taxonomy'] );
+ }
+ }
+
+ /**
+ * @testdox Should flush counts the caller already queued for the taxonomy before unregistering it.
+ */
+ public function test_wc_delete_attribute_flushes_counts_queued_by_the_caller(): void {
+ $slug = $this->get_unique_attribute_slug( 'caller-queue' );
+ $attribute = $this->create_registered_attribute( $slug );
+
+ $term = wp_insert_term( 'Caller deleted term', $attribute['taxonomy'] );
+ $this->assertIsArray( $term, 'The fixture term should be created.' );
+
+ $product_id = wp_insert_post(
+ array(
+ 'post_type' => 'product',
+ 'post_title' => 'Caller queued counting product',
+ 'post_status' => 'publish',
+ )
+ );
+ $this->assertIsInt( $product_id, 'The fixture product should be created.' );
+ wp_set_object_terms( $product_id, array( (int) $term['term_id'] ), $attribute['taxonomy'] );
+
+ wp_defer_term_counting( true );
+
+ try {
+ // The caller removes the term itself, which queues a count for the taxonomy.
+ // The attribute then has no terms left for wc_delete_attribute() to remove.
+ $this->assertTrue( wp_delete_term( (int) $term['term_id'], $attribute['taxonomy'] ), 'The fixture term should be deleted by the caller.' );
+
+ $this->assertTrue( wc_delete_attribute( $attribute['id'] ), 'The attribute should be deleted successfully.' );
+ $this->assertFalse( taxonomy_exists( $attribute['taxonomy'] ), 'The deleted attribute taxonomy should be unregistered.' );
+
+ $this->assertSame( array(), $this->drain_deferred_term_counts(), 'Draining the queue must not resolve the unregistered taxonomy.' );
+ } finally {
+ $this->drain_deferred_term_counts();
+ wp_delete_post( $product_id, true );
+ $this->clean_up_attribute_test_state( array( $attribute['id'] ), $attribute['taxonomy'] );
+ }
+ }
+
/**
* Describes the behavior of the wc_update_attribute() function.
*
@@ -358,4 +543,98 @@ class WC_Attribute_Functions_Test extends \WC_Unit_Test_Case {
array( 'ĂnîC°Dę', 'anicde' ),
);
}
+
+ /**
+ * Creates a uniquely named attribute with entries in both runtime registries.
+ *
+ * @param string $slug Attribute slug.
+ * @return array{id: int, taxonomy: string, wp_taxonomy: WP_Taxonomy, wc_attribute: object}
+ */
+ private function create_registered_attribute( string $slug ): array {
+ global $wc_product_attributes;
+
+ $attribute_id = wc_create_attribute(
+ array(
+ 'name' => 'Runtime attribute',
+ 'slug' => $slug,
+ )
+ );
+ $this->assertIsInt( $attribute_id, 'The runtime attribute fixture should be created.' );
+
+ $taxonomy = wc_attribute_taxonomy_name( $slug );
+ register_taxonomy( $taxonomy, array( 'product' ) );
+ $wp_taxonomy = get_taxonomy( $taxonomy );
+ $this->assertInstanceOf( WP_Taxonomy::class, $wp_taxonomy, 'The runtime attribute fixture should have a registered taxonomy.' );
+
+ $wc_attribute = (object) array( 'attribute_id' => $attribute_id );
+ $wc_product_attributes[ $taxonomy ] = $wc_attribute;
+
+ return array(
+ 'id' => $attribute_id,
+ 'taxonomy' => $taxonomy,
+ 'wp_taxonomy' => $wp_taxonomy,
+ 'wc_attribute' => $wc_attribute,
+ );
+ }
+
+ /**
+ * Stops deferring term counts, which drains WordPress's queue, and captures any PHP warnings raised while doing so.
+ *
+ * The queue resolves each taxonomy by name, so a queued taxonomy that was unregistered
+ * in the meantime surfaces as a warning. Capturing it keeps the assertion independent
+ * of PHPUnit converting warnings to exceptions.
+ *
+ * @return string[] Warning messages raised during the drain.
+ */
+ private function drain_deferred_term_counts(): array {
+ $warnings = array();
+
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception, and PHPUnit 10 stops doing that.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ $warnings[] = $errstr;
+ return true;
+ }
+ );
+
+ try {
+ wp_defer_term_counting( false );
+ } finally {
+ restore_error_handler();
+ }
+
+ return $warnings;
+ }
+
+ /**
+ * Returns a unique attribute slug within WordPress's taxonomy byte limit.
+ *
+ * @param string $context Slug context.
+ * @return string
+ */
+ private function get_unique_attribute_slug( string $context ): string {
+ return 'runtime-' . $context . '-' . strtolower( wp_generate_password( 5, false, false ) );
+ }
+
+ /**
+ * Removes database and runtime state created by an attribute deletion test.
+ *
+ * @param array<int|null> $attribute_ids Attribute IDs to remove.
+ * @param string $taxonomy Attribute taxonomy name.
+ * @return void
+ */
+ private function clean_up_attribute_test_state( array $attribute_ids, string $taxonomy ): void {
+ global $wc_product_attributes;
+
+ if ( taxonomy_exists( $taxonomy ) ) {
+ unregister_taxonomy( $taxonomy );
+ }
+ unset( $wc_product_attributes[ $taxonomy ] );
+
+ foreach ( $attribute_ids as $attribute_id ) {
+ if ( is_int( $attribute_id ) ) {
+ wc_delete_attribute( $attribute_id );
+ }
+ }
+ }
}