Commit d2dfda2979e for woocommerce

commit d2dfda2979eac21e1c6c59fb6469f887d8f9bda2
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Wed Jul 15 16:47:28 2026 +0100

    Fix order edit AJAX error and prevent `autosave.js` from loading on WC screens (#66579)

diff --git a/plugins/woocommerce/changelog/66411-fix-order-edit-sample-permalink-403 b/plugins/woocommerce/changelog/66411-fix-order-edit-sample-permalink-403
new file mode 100644
index 00000000000..946bc4bf5b6
--- /dev/null
+++ b/plugins/woocommerce/changelog/66411-fix-order-edit-sample-permalink-403
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Improve lost connection detection on WooCommerce admin screens
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index be2d8b78a7d..8fc13498a42 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -4002,6 +4002,13 @@ mark.notice {
 	margin: 0 0 0 10px;
 }

+// Mirrors the WP core rule for #lost-connection-notice.
+#wc-lost-connection-notice .spinner {
+	visibility: visible;
+	float: left;
+	margin: 0 5px 0 0;
+}
+
 a.export_rates,
 a.import_rates {
 	float: right;
diff --git a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js
index ce4154bed71..5a1c9ef6a3f 100644
--- a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js
+++ b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js
@@ -5,6 +5,19 @@
 			return;
 		}

+		// Toggle #wc-lost-connection-notice via WP core's heartbeat events (see WC_Admin_Assets::render_lost_connection_notice()).
+		if ( woocommerce_admin.show_lost_connection_notice ) {
+			$( document )
+				.on( 'heartbeat-connection-lost.wc-lost-connection-notice', function ( event, error, status ) {
+					if ( 'timeout' === error || 603 === status ) {
+						$( '#wc-lost-connection-notice' ).show();
+					}
+				} )
+				.on( 'heartbeat-connection-restored.wc-lost-connection-notice', function () {
+					$( '#wc-lost-connection-notice' ).hide();
+				} );
+		}
+
 		// Add buttons to product screen.
 		var $product_screen = $( '.edit-php.post-type-product' ),
 			$title_action = $product_screen.find( '.page-title-action:first' ),
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index d627054ad50..2c74470553a 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -42,47 +42,44 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 		}

 		/**
-		 * Render WordPress core's #lost-connection-notice markup on Woo admin
-		 * screens that don't already get it for free.
+		 * Whether the current screen should get the #wc-lost-connection-notice markup and toggle handler.
+		 * Limited to Woo admin screens, minus wc-admin React pages and classic post edit screens, which get WP
+		 * core's own notice. Classic order edit is the exception: core's notice there wrongly claims local
+		 * autosave backups are running, which disable_autosave() has turned off.
 		 *
-		 * WP core renders this element on classic post-type edit pages (via
-		 * edit-form-advanced.php), and wp-autosave.js toggles it in response
-		 * to Heartbeat events. By echoing the same markup here and enqueueing
-		 * the `autosave` script (see admin_scripts()), Woo admin screens
-		 * inherit the same offline awareness without any new copy, styling,
-		 * or behavior.
-		 *
-		 * Translation strings use the 'default' text domain so WP core's
-		 * existing translations apply.
-		 *
-		 * Skipped on:
-		 * - Classic post-type edit screens (WP core already renders the notice).
-		 * - wc-admin React pages (they use their own layout rather than the
-		 *   standard admin_notices position).
-		 *
-		 * @return void
+		 * @return bool Whether to render and toggle the notice.
 		 */
-		public function render_lost_connection_notice() {
+		private function should_show_lost_connection_notice() {
 			$screen = get_current_screen();
-			if ( ! $screen ) {
-				return;
-			}

-			if ( 'post' === $screen->base ) {
-				return;
+			if ( ! $screen || ! in_array( $screen->id, wc_get_screen_ids(), true ) ) {
+				return false;
 			}

 			$is_wc_admin_page = class_exists( '\Automattic\WooCommerce\Admin\PageController' )
 				&& \Automattic\WooCommerce\Admin\PageController::is_admin_page();
 			if ( $is_wc_admin_page ) {
-				return;
+				return false;
 			}

-			if ( ! in_array( $screen->id, wc_get_screen_ids(), true ) ) {
+			$is_classic_order_edit_screen = 'post' === $screen->base
+				&& in_array( $screen->post_type, wc_get_order_types( 'order-meta-boxes' ), true );
+
+			return 'post' !== $screen->base || $is_classic_order_edit_screen;
+		}
+
+		/**
+		 * Render a #wc-lost-connection-notice mirroring WP core's own notice on post edit screens.
+		 * See should_show_lost_connection_notice() for which screens get it.
+		 *
+		 * @return void
+		 */
+		public function render_lost_connection_notice() {
+			if ( ! $this->should_show_lost_connection_notice() ) {
 				return;
 			}
 			?>
-			<div id="lost-connection-notice" class="notice error hidden">
+			<div id="wc-lost-connection-notice" class="notice error hidden">
 				<p>
 					<span class="spinner"></span>
 					<strong><?php esc_html_e( 'Connection lost.' ); /* phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Reusing WP core translation. */ ?></strong>
@@ -466,12 +463,11 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 				wp_enqueue_script( 'woocommerce_admin' );
 				wp_enqueue_script( 'wc-enhanced-select' );

-				// Pair the #lost-connection-notice markup with core's autosave script.
-				// See render_lost_connection_notice() for scoping rationale.
-				$is_wc_admin_page = class_exists( '\Automattic\WooCommerce\Admin\PageController' )
-					&& \Automattic\WooCommerce\Admin\PageController::is_admin_page();
-				if ( 'post' !== ( $screen->base ?? '' ) && ! $is_wc_admin_page ) {
-					wp_enqueue_script( 'autosave' );
+				$show_lost_connection_notice = $this->should_show_lost_connection_notice();
+
+				// The notice is toggled via heartbeat events (see woocommerce_admin.js), so ensure the emitter is loaded.
+				if ( $show_lost_connection_notice ) {
+					wp_enqueue_script( 'heartbeat' );
 				}

 				wp_enqueue_script( 'jquery-ui-sortable' );
@@ -510,6 +506,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 						'import_products' => current_user_can( 'import' ) ? esc_url_raw( admin_url( 'edit.php?post_type=product&page=product_importer' ) ) : null,
 						'export_products' => current_user_can( 'export' ) ? esc_url_raw( admin_url( 'edit.php?post_type=product&page=product_exporter' ) ) : null,
 					),
+					'show_lost_connection_notice'       => $show_lost_connection_notice,
 				);

 				wp_localize_script( 'woocommerce_admin', 'woocommerce_admin', $params );
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
index aef6a07c8c6..8f01dced6ba 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-data.php
@@ -776,8 +776,8 @@ class WC_Meta_Box_Order_Data {
 							if ( apply_filters( 'woocommerce_enable_order_notes_field', 'yes' === get_option( 'woocommerce_enable_order_comments', 'yes' ) ) ) :
 								?>
 								<p class="form-field form-field-wide">
-									<label for="customer_note"><?php esc_html_e( 'Customer provided note', 'woocommerce' ); ?>:</label>
-									<textarea rows="1" cols="40" name="customer_note" tabindex="6" id="customer_note" placeholder="<?php esc_attr_e( 'Customer notes about the order', 'woocommerce' ); ?>"><?php echo wp_kses( $order->get_customer_note(), array( 'br' => array() ) ); ?></textarea>
+									<label for="excerpt"><?php esc_html_e( 'Customer provided note', 'woocommerce' ); ?>:</label>
+									<textarea rows="1" cols="40" name="customer_note" tabindex="6" id="excerpt" placeholder="<?php esc_attr_e( 'Customer notes about the order', 'woocommerce' ); ?>"><?php echo wp_kses( $order->get_customer_note(), array( 'br' => array() ) ); ?></textarea>
 								</p>
 							<?php endif; ?>
 						</div>
diff --git a/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
index 9891dd2190d..a6376dd7c03 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
@@ -198,6 +198,35 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
 		);
 	} );

+	test( 'saving an order does not trigger a false unsaved-changes warning', async ( {
+		page,
+	} ) => {
+		if ( process.env.DISABLE_HPOS === '1' ) {
+			await page.goto(
+				`wp-admin/post.php?post=${ orderId }&action=edit`
+			);
+		} else {
+			await page.goto(
+				`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
+			);
+		}
+
+		const dialogMessages: string[] = [];
+		page.on( 'dialog', async ( dialog ) => {
+			dialogMessages.push( dialog.message() );
+			await dialog.accept();
+		} );
+
+		await page.locator( 'button.save_order' ).click();
+		await expect(
+			page
+				.locator( 'div.notice-success > p' )
+				.filter( { hasText: 'Order updated.' } )
+		).toBeVisible();
+
+		expect( dialogMessages ).toEqual( [] );
+	} );
+
 	test( 'can add and delete order notes', async ( { page } ) => {
 		// open order we created
 		await page.goto(
@@ -380,6 +409,42 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
 			).toBeVisible();
 		} );
 	} );
+
+	test( 'shows the lost connection notice when the heartbeat request fails', async ( {
+		page,
+	} ) => {
+		if ( process.env.DISABLE_HPOS === '1' ) {
+			await page.goto(
+				`wp-admin/post.php?post=${ orderId }&action=edit`
+			);
+		} else {
+			await page.goto(
+				`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
+			);
+		}
+
+		const notice = page.locator( '#wc-lost-connection-notice' );
+		await expect( notice ).toBeHidden();
+
+		// Dispatch the same event heartbeat.js fires on a real timeout.
+		await page.evaluate( () =>
+			// @ts-expect-error jQuery isn't typed here.
+			jQuery( document ).trigger( 'heartbeat-connection-lost', [
+				'timeout',
+				0,
+			] )
+		);
+
+		await expect( notice ).toBeVisible();
+		await expect( notice ).toContainText( 'Connection lost.' );
+
+		await page.evaluate( () =>
+			// @ts-expect-error jQuery isn't typed here.
+			jQuery( document ).trigger( 'heartbeat-connection-restored' )
+		);
+
+		await expect( notice ).toBeHidden();
+	} );
 } );

 test.describe(
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
new file mode 100644
index 00000000000..4fa1302a7bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
@@ -0,0 +1,109 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Admin_Assets.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+class WC_Admin_Assets_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WC_Admin_Assets
+	 */
+	private $sut;
+
+	/**
+	 * Set up before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new WC_Admin_Assets();
+		$this->sut->register_scripts();
+	}
+
+	/**
+	 * Tear down after each test.
+	 */
+	public function tearDown(): void {
+		unset( $_GET['page'] );
+		wp_dequeue_script( 'woocommerce_admin' );
+		wp_dequeue_script( 'heartbeat' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should set up the lost connection notice and heartbeat correctly per screen, and never re-enqueue autosave.
+	 * @testWith ["woocommerce_page_wc-orders", "woocommerce_page_wc-orders", "", false, true]
+	 *           ["shop_order", "post", "shop_order", false, true]
+	 *           ["product", "post", "product", false, false]
+	 *           ["toplevel_page_woocommerce", "toplevel_page_woocommerce", "", true, false]
+	 *
+	 * @param string $screen_id   Screen id.
+	 * @param string $base        Screen base.
+	 * @param string $post_type   Screen post type, if any.
+	 * @param bool   $is_wc_admin Whether to simulate a wc-admin React page.
+	 * @param bool   $expected    Expected show_lost_connection_notice value.
+	 */
+	public function test_admin_scripts_lost_connection_notice_setup( string $screen_id, string $base, string $post_type, bool $is_wc_admin, bool $expected ): void {
+		set_current_screen();
+		$screen            = get_current_screen();
+		$screen->id        = $screen_id;
+		$screen->base      = $base;
+		$screen->post_type = $post_type;
+		$_GET['page']      = $is_wc_admin ? 'wc-admin' : '';
+
+		$this->sut->admin_scripts();
+
+		$localized = wp_scripts()->get_data( 'woocommerce_admin', 'data' );
+		$this->assertIsString( $localized, 'woocommerce_admin should be localized on this screen' );
+		$this->assertStringContainsString(
+			'"show_lost_connection_notice":"' . ( $expected ? '1' : '' ) . '"',
+			$localized,
+			'show_lost_connection_notice should be ' . ( $expected ? 'true' : 'false' ) . " for screen '{$screen_id}'"
+		);
+
+		$this->assertSame(
+			$expected,
+			wp_scripts()->query( 'heartbeat', 'enqueued' ),
+			'heartbeat should be enqueued exactly on the screens that use the notice'
+		);
+
+		$this->assertFalse(
+			wp_scripts()->query( 'autosave', 'enqueued' ),
+			'autosave must never be enqueued, since it turns on unrelated post.js handlers'
+		);
+	}
+
+	/**
+	 * @testdox Should render the lost connection notice markup only where expected.
+	 * @testWith ["woocommerce_page_wc-orders", "woocommerce_page_wc-orders", "", true]
+	 *           ["shop_order", "post", "shop_order", true]
+	 *           ["product", "post", "product", false]
+	 *
+	 * @param string $screen_id     Screen id.
+	 * @param string $base          Screen base.
+	 * @param string $post_type     Screen post type, if any.
+	 * @param bool   $should_render Whether the markup should render at all.
+	 */
+	public function test_render_lost_connection_notice_markup( string $screen_id, string $base, string $post_type, bool $should_render ): void {
+		set_current_screen();
+		$screen            = get_current_screen();
+		$screen->id        = $screen_id;
+		$screen->base      = $base;
+		$screen->post_type = $post_type;
+
+		ob_start();
+		$this->sut->render_lost_connection_notice();
+		$output = ob_get_clean();
+
+		if ( ! $should_render ) {
+			$this->assertSame( '', $output, "No notice should render for screen '{$screen_id}'" );
+			return;
+		}
+
+		$this->assertStringContainsString( 'id="wc-lost-connection-notice"', $output );
+	}
+}