Commit bfe77df66a6 for woocommerce

commit bfe77df66a657ecc95343c1e373557f144d09084
Author: Jill Q. <jill.quek@automattic.com>
Date:   Thu Apr 23 15:21:38 2026 +0800

    Add offline awareness to WooCommerce admin (reuses WP core #lost-connection-notice) (#64334)

    * Add offline awareness to WooCommerce admin

    When a merchant loses their connection while using WooCommerce admin,
    today they get silent failure on most screens. Save a product from
    Settings, get no feedback. Edit a coupon, nothing happens. Click save
    in Orders, the work is lost with no indication why.

    WordPress core already solved this — the classic post editor shows
    #lost-connection-notice ("Connection lost. Saving has been disabled
    until you are reconnected.") whenever Heartbeat detects the server is
    unreachable. But that notice is only rendered on classic post-type
    edit pages (via edit-form-advanced.php). Every other Woo admin screen
    is silent.

    This PR reuses WordPress core's existing pattern on Woo admin screens
    that don't already get it:

    - Echoes the exact #lost-connection-notice markup via admin_notices,
      using WP's default text domain so existing translations apply.
    - Enqueues the `autosave` script, which already knows how to toggle
      that notice in response to Heartbeat events.
    - Skips pages where WP core already handles this (classic post edit)
      and where the rendering context differs (wc-admin React pages).

    Because we're reusing WP's markup, styling, copy, and behavior, we
    inherit any future improvements to those elements from Core for free.

    Also includes two related improvements that surfaced during this work:

    1. Fix the dormant useNetworkStatus hook so its initial state reflects
       navigator.onLine (it was always starting as false, meaning pages
       loaded while offline looked normal to any future consumer).

    2. Extend createNoticesFromResponse to surface raw network failures
       (TypeError: Failed to fetch) that previously fell through silently.
       Uses Gutenberg's existing "Updating failed. You are probably
       offline." string, so translators don't have to retranslate.

    Part of: RSM — Woo Sprinkles

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Address review feedback

    - Narrow isSilentNetworkFailure: also treat response.code and
      response.error_data as signals of a structured API error so we don't
      mask them with the generic offline notice (CodeRabbit + dmallory42).
    - Trim verbose comments in notices/index.js per dmallory42's pass.
    - Switch lib/notices/test/index.js to the beforeAll + afterEach pattern
      used in use-network-status.test.tsx so a failing assertion can't leak
      a fake offline navigator state to subsequent tests (CodeRabbit + dmallory42).
    - Shorten the duplicate "reuse WP core offline handling" comment in
      admin_scripts() to a one-line pointer at render_lost_connection_notice.
    - Remove the redundant `$screen ? $screen->base : ''` check — we're
      already inside `in_array( $screen_id, wc_get_screen_ids() )`, which
      guarantees $screen is set.
    - Drop stray trailing blank line in legacy/css/admin.scss.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Fix: keep null guard on \$screen->base to satisfy PHPStan

    The earlier removal of `\$screen ? \$screen->base : ''` logically held
    (we're already inside `in_array( \$screen_id, wc_get_screen_ids() )`
    which would be false for a null screen) but PHPStan can't infer that
    relationship. Use a compact `\$screen->base ?? ''` null-coalesce
    instead so we keep the one-line shape dmallory suggested without
    reintroducing the PHPStan warning.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Test: harden navigator.onLine cleanup and assert call counts

    Two follow-ups from the second CodeRabbit review:

    - In jsdom, `navigator.onLine` lives on Navigator.prototype, not as an
      own property, so `Object.getOwnPropertyDescriptor(window.navigator,
      'onLine')` returns undefined. The existing `afterEach` restore was a
      no-op in that case, meaning a test's own override would leak to
      later tests. Fall back to `delete window.navigator.onLine` so
      inherited prototype behavior is restored.

    - Add `expect(createNotice).toHaveBeenCalledTimes(1)` to both offline
      tests so we'd catch any future code path that emits a duplicate or
      raw notice after the friendly offline copy.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Fix: prettier formatting in offline test afterEach

    The multi-line Object.defineProperty call was short enough to fit on
    a single line; prettier rejects breaking it. Collapse to inline.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-offline-banner b/plugins/woocommerce/changelog/add-offline-banner
new file mode 100644
index 00000000000..197369c8a0f
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-offline-banner
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add an offline banner to WooCommerce admin that appears when the browser loses its connection, and surface a clear notice when save requests fail silently due to network loss.
diff --git a/plugins/woocommerce/client/admin/client/lib/notices/index.js b/plugins/woocommerce/client/admin/client/lib/notices/index.js
index 43074fc63e0..62c87778251 100644
--- a/plugins/woocommerce/client/admin/client/lib/notices/index.js
+++ b/plugins/woocommerce/client/admin/client/lib/notices/index.js
@@ -2,10 +2,62 @@
  * External dependencies
  */
 import { dispatch } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Raw network failures (e.g. the browser is offline, or DNS cannot resolve)
+ * arrive as a `TypeError: Failed to fetch` and have no `message`/`errors`
+ * payload. Without special handling, these fall through silently and the
+ * merchant sees nothing when a save fails.
+ *
+ * Returns true if the response looks like a browser-level network failure.
+ *
+ * @param {unknown} response The rejection value from apiFetch.
+ * @return {boolean} Whether this looks like a silent network failure.
+ */
+function isSilentNetworkFailure( response ) {
+	if ( ! response ) {
+		return false;
+	}
+
+	if ( response instanceof TypeError ) {
+		return true;
+	}
+
+	if ( typeof response !== 'object' ) {
+		return false;
+	}
+
+	// Any of these properties means the API returned a structured error;
+	// fall through to the existing handling below so the merchant sees the
+	// real message rather than a generic offline copy.
+	const hasStructuredPayload =
+		( 'message' in response && response.message ) ||
+		( 'errors' in response &&
+			response.errors &&
+			Object.keys( response.errors ).length ) ||
+		'code' in response ||
+		'error_data' in response;
+
+	return (
+		! hasStructuredPayload &&
+		typeof window !== 'undefined' &&
+		window.navigator?.onLine === false
+	);
+}

 export function createNoticesFromResponse( response ) {
 	const { createNotice } = dispatch( 'core/notices' );

+	if ( isSilentNetworkFailure( response ) ) {
+		// String matches Gutenberg's existing offline copy — reuses their translations.
+		createNotice(
+			'error',
+			__( 'Updating failed. You are probably offline.', 'woocommerce' )
+		);
+		return;
+	}
+
 	if (
 		response.error_data &&
 		response.errors &&
diff --git a/plugins/woocommerce/client/admin/client/lib/notices/test/index.js b/plugins/woocommerce/client/admin/client/lib/notices/test/index.js
index 3f4306afa64..1620722e1cd 100644
--- a/plugins/woocommerce/client/admin/client/lib/notices/test/index.js
+++ b/plugins/woocommerce/client/admin/client/lib/notices/test/index.js
@@ -15,10 +15,30 @@ jest.mock( '@wordpress/data', () => ( {
 } ) );

 describe( 'createNoticesFromResponse', () => {
+	let originalOnLine;
+
+	beforeAll( () => {
+		originalOnLine = Object.getOwnPropertyDescriptor(
+			window.navigator,
+			'onLine'
+		);
+	} );
+
 	beforeEach( () => {
 		jest.clearAllMocks();
 	} );

+	afterEach( () => {
+		if ( originalOnLine ) {
+			Object.defineProperty( window.navigator, 'onLine', originalOnLine );
+		} else {
+			// When `onLine` lives on the prototype (e.g. jsdom), the own
+			// descriptor is undefined. Delete any own override a test
+			// defined so inherited prototype behavior is restored.
+			delete window.navigator.onLine;
+		}
+	} );
+
 	const { createNotice } = dispatch( 'core/notices' );

 	test( 'should create notice based on message when no errors exist', () => {
@@ -67,4 +87,30 @@ describe( 'createNoticesFromResponse', () => {
 		createNoticesFromResponse( response );
 		expect( createNotice ).not.toHaveBeenCalled();
 	} );
+
+	test( 'should surface a friendly offline notice when response is a raw TypeError', () => {
+		const response = new TypeError( 'Failed to fetch' );
+
+		createNoticesFromResponse( response );
+		expect( createNotice ).toHaveBeenCalledWith(
+			'error',
+			'Updating failed. You are probably offline.'
+		);
+		expect( createNotice ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	test( 'should surface a friendly offline notice for an empty rejection while navigator is offline', () => {
+		Object.defineProperty( window.navigator, 'onLine', {
+			configurable: true,
+			value: false,
+		} );
+
+		createNoticesFromResponse( {} );
+		expect( createNotice ).toHaveBeenCalledWith(
+			'error',
+			'Updating failed. You are probably offline.'
+		);
+		expect( createNotice ).toHaveBeenCalledTimes( 1 );
+		// afterEach restores navigator.onLine.
+	} );
 } );
diff --git a/plugins/woocommerce/client/admin/client/utils/react-hooks/test/use-network-status.test.tsx b/plugins/woocommerce/client/admin/client/utils/react-hooks/test/use-network-status.test.tsx
index 113558fe4d8..324386b30c1 100644
--- a/plugins/woocommerce/client/admin/client/utils/react-hooks/test/use-network-status.test.tsx
+++ b/plugins/woocommerce/client/admin/client/utils/react-hooks/test/use-network-status.test.tsx
@@ -9,11 +9,38 @@ import { renderHook, act } from '@testing-library/react-hooks';
 import { useNetworkStatus } from '../use-network-status';

 describe( 'useNetworkStatus', () => {
-	it( 'should initially set isNetworkOffline to false', () => {
+	// Restore navigator.onLine between tests — we mutate it below.
+	let originalOnLine: PropertyDescriptor | undefined;
+	beforeAll( () => {
+		originalOnLine = Object.getOwnPropertyDescriptor(
+			window.navigator,
+			'onLine'
+		);
+	} );
+	afterEach( () => {
+		if ( originalOnLine ) {
+			Object.defineProperty( window.navigator, 'onLine', originalOnLine );
+		}
+	} );
+
+	it( 'should initially set isNetworkOffline to false when navigator is online', () => {
+		Object.defineProperty( window.navigator, 'onLine', {
+			configurable: true,
+			value: true,
+		} );
 		const { result } = renderHook( () => useNetworkStatus() );
 		expect( result.current ).toBe( false );
 	} );

+	it( 'should initially set isNetworkOffline to true when navigator is already offline at mount', () => {
+		Object.defineProperty( window.navigator, 'onLine', {
+			configurable: true,
+			value: false,
+		} );
+		const { result } = renderHook( () => useNetworkStatus() );
+		expect( result.current ).toBe( true );
+	} );
+
 	it( 'should set isNetworkOffline to true when window goes offline', () => {
 		const { result } = renderHook( () => useNetworkStatus() );
 		act( () => {
diff --git a/plugins/woocommerce/client/admin/client/utils/react-hooks/use-network-status.tsx b/plugins/woocommerce/client/admin/client/utils/react-hooks/use-network-status.tsx
index fa15d7ec1aa..d64db8be82d 100644
--- a/plugins/woocommerce/client/admin/client/utils/react-hooks/use-network-status.tsx
+++ b/plugins/woocommerce/client/admin/client/utils/react-hooks/use-network-status.tsx
@@ -4,7 +4,12 @@
 import { useState, useEffect } from '@wordpress/element';

 export const useNetworkStatus = () => {
-	const [ isNetworkOffline, setIsNetworkOffline ] = useState( false );
+	// Initialize from navigator.onLine so pages loaded while offline
+	// reflect the correct state immediately, not only after an online/offline
+	// event fires. Falls back to false during SSR where navigator is undefined.
+	const [ isNetworkOffline, setIsNetworkOffline ] = useState(
+		typeof navigator !== 'undefined' && navigator.onLine === false
+	);

 	useEffect( () => {
 		const offlineEventHandler = () => {
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index 24c8f462208..19dfe58d97d 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -38,6 +38,58 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 			add_action( 'admin_enqueue_scripts', array( $this, 'admin_styles' ) );
 			add_action( 'admin_enqueue_scripts', array( $this, 'admin_scripts' ) );
 			add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_command_palette_assets' ) );
+			add_action( 'admin_notices', array( $this, 'render_lost_connection_notice' ) );
+		}
+
+		/**
+		 * Render WordPress core's #lost-connection-notice markup on Woo admin
+		 * screens that don't already get it for free.
+		 *
+		 * 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
+		 */
+		public function render_lost_connection_notice() {
+			$screen = get_current_screen();
+			if ( ! $screen ) {
+				return;
+			}
+
+			if ( 'post' === $screen->base ) {
+				return;
+			}
+
+			$is_wc_admin_page = class_exists( '\Automattic\WooCommerce\Admin\PageController' )
+				&& \Automattic\WooCommerce\Admin\PageController::is_admin_page();
+			if ( $is_wc_admin_page ) {
+				return;
+			}
+
+			if ( ! in_array( $screen->id, wc_get_screen_ids(), true ) ) {
+				return;
+			}
+			?>
+			<div id="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>
+					<?php esc_html_e( 'Saving has been disabled until you are reconnected.' ); /* phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Reusing WP core translation. */ ?>
+				</p>
+			</div>
+			<?php
 		}

 		/**
@@ -414,6 +466,14 @@ 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' );
+				}
+
 				wp_enqueue_script( 'jquery-ui-sortable' );
 				wp_enqueue_script( 'jquery-ui-autocomplete' );