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' );