Commit f5accf16c33 for woocommerce

commit f5accf16c337e54f925293ef6aab03705905ccb0
Author: Alefe Souza <contact@alefesouza.com>
Date:   Wed Sep 30 20:24:07 2026 -0300

    Fix classic checkout stuck on a spinner after logging in and going back (#68758)

diff --git a/plugins/woocommerce/changelog/32070-classic-checkout-stale-nonce-reload b/plugins/woocommerce/changelog/32070-classic-checkout-stale-nonce-reload
new file mode 100644
index 00000000000..0ef85dbbf52
--- /dev/null
+++ b/plugins/woocommerce/changelog/32070-classic-checkout-stale-nonce-reload
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Reload the classic checkout when the browser restores a copy rendered before the shopper logged in, instead of leaving it stuck on a spinner after the order review request is rejected.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 9f7bcb8d135..a56a0b95afa 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -397,7 +397,22 @@ class WC_AJAX {
 	 * @return void
 	 */
 	public static function update_order_review() {
-		check_ajax_referer( 'update-order-review', 'security' );
+		// A rejected nonce usually means the browser restored a checkout rendered for an earlier session,
+		// e.g. going back after logging in. Reload it once; if the fresh page is rejected too, show the expired notice.
+		// Without a session cookie the reload flag can't persist, so skip the reload to avoid an endless loop.
+		if ( ! check_ajax_referer( 'update-order-review', 'security', false ) ) {
+			$can_remember_reload = ! ( WC()->session instanceof WC_Session_Handler ) || WC()->session->has_session();
+
+			if ( ! $can_remember_reload || WC()->session->get( 'reload_checkout_for_nonce' ) ) {
+				unset( WC()->session->reload_checkout_for_nonce );
+				self::update_order_review_expired();
+			}
+
+			WC()->session->set( 'reload_checkout_for_nonce', true );
+			wp_send_json( array( 'reload' => true ) );
+		}
+
+		unset( WC()->session->reload_checkout_for_nonce );

 		wc_maybe_define_constant( 'WOOCOMMERCE_CHECKOUT', true );

diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index a731f820d8b..103e26c5350 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -2787,6 +2787,91 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		}
 	}

+	/**
+	 * @testdox A rejected checkout update nonce should ask for one reload, then show the expired notice.
+	 */
+	public function test_update_order_review_reloads_once_for_rejected_nonce(): void {
+		$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Preserve test globals before building the request.
+
+		try {
+			wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+			unset( WC()->session->reload_checkout_for_nonce );
+
+			$_POST = array(
+				'security'  => 'stale-nonce',
+				'post_data' => '',
+			);
+
+			$response = $this->do_ajax( 'woocommerce_update_order_review' );
+
+			$this->assertSame( array( 'reload' => true ), $response, 'The first rejected nonce should only ask the checkout to reload.' );
+			$this->assertTrue( WC()->session->get( 'reload_checkout_for_nonce' ), 'The session should remember that a reload was requested.' );
+
+			$response = $this->do_ajax( 'woocommerce_update_order_review' );
+
+			$this->assertIsArray( $response, 'The second rejected nonce should return a JSON array.' );
+			$this->assertArrayNotHasKey( 'reload', $response, 'A second rejected nonce should not reload again.' );
+			$this->assertStringContainsString( 'Sorry, your session has expired.', $response['fragments']['form.woocommerce-checkout'], 'A second rejected nonce should show the expired notice.' );
+		} finally {
+			unset( WC()->session->reload_checkout_for_nonce );
+			$_POST = $original_post;
+		}
+	}
+
+	/**
+	 * @testdox A rejected checkout update nonce should show the expired notice when the guest has no session to remember the reload.
+	 */
+	public function test_update_order_review_skips_reload_without_session(): void {
+		$original_post    = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Preserve test globals before building the request.
+		$original_session = WC()->session;
+
+		try {
+			wp_set_current_user( 0 );
+			WC()->session = new WC_Session_Handler();
+			WC()->session->init_session_cookie();
+
+			$this->assertFalse( WC()->session->has_session(), 'The guest should start without a cookie-backed session.' );
+
+			$_POST = array(
+				'security'  => 'stale-nonce',
+				'post_data' => '',
+			);
+
+			$response = $this->do_ajax( 'woocommerce_update_order_review' );
+
+			$this->assertIsArray( $response, 'The rejected nonce should return a JSON array.' );
+			$this->assertArrayNotHasKey( 'reload', $response, 'Without a session the checkout should not be asked to reload.' );
+			$this->assertStringContainsString( 'Sorry, your session has expired.', $response['fragments']['form.woocommerce-checkout'], 'Without a session the expired notice should show.' );
+		} finally {
+			WC()->session = $original_session;
+			$_POST        = $original_post;
+		}
+	}
+
+	/**
+	 * @testdox A checkout update with a valid nonce should clear the pending nonce reload flag.
+	 */
+	public function test_update_order_review_valid_nonce_clears_reload_flag(): void {
+		$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Preserve test globals before building the request.
+
+		try {
+			WC()->cart->empty_cart();
+			WC()->session->set( 'reload_checkout_for_nonce', true );
+
+			$_POST = array(
+				'security'  => wp_create_nonce( 'update-order-review' ),
+				'post_data' => '',
+			);
+
+			$this->do_ajax( 'woocommerce_update_order_review' );
+
+			$this->assertNull( WC()->session->get( 'reload_checkout_for_nonce' ), 'A valid nonce should let a later stale page reload again.' );
+		} finally {
+			unset( WC()->session->reload_checkout_for_nonce );
+			$_POST = $original_post;
+		}
+	}
+
 	/**
 	 * Does the 'hard work' of triggering an ajax endpoint and capturing the response.
 	 *