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