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 );
+			}
+		}
+	}
 }