Commit 8e6b1847e4b for woocommerce
commit 8e6b1847e4bd7bcdb033bb742f76fa9711e684cd
Author: Luigi Teschio <gigitux@gmail.com>
Date: Thu May 21 15:48:04 2026 +0200
Add products app crash fallback (#65236)
* Add products app crash fallback
* Add changelog entry for products app crash fallback
diff --git a/packages/js/experimental-products-app/changelog/fix-products-app-crash-fallback b/packages/js/experimental-products-app/changelog/fix-products-app-crash-fallback
new file mode 100644
index 00000000000..5e2bb37c56c
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-products-app-crash-fallback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a recovery message with GitHub and survey links when the experimental products app crashes.
diff --git a/packages/js/experimental-products-app/src/app-error-boundary.test.tsx b/packages/js/experimental-products-app/src/app-error-boundary.test.tsx
new file mode 100644
index 00000000000..99320024371
--- /dev/null
+++ b/packages/js/experimental-products-app/src/app-error-boundary.test.tsx
@@ -0,0 +1,116 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { AppErrorBoundary } from './app-error-boundary';
+import { FEEDBACK_URL, GITHUB_ISSUES_URL } from './constants';
+
+jest.mock( '@wordpress/i18n', () => ( {
+ __: jest.fn( ( message ) => message ),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+ Button: ( {
+ children,
+ href,
+ onClick,
+ rel,
+ target,
+ }: React.PropsWithChildren< {
+ href?: string;
+ onClick?: () => void;
+ rel?: string;
+ target?: string;
+ } > ) =>
+ href ? (
+ <a href={ href } rel={ rel } target={ target }>
+ { children }
+ </a>
+ ) : (
+ <button onClick={ onClick }>{ children }</button>
+ ),
+} ) );
+
+jest.mock( '@wordpress/ui', () => ( {
+ EmptyState: {
+ Root: ( {
+ children,
+ className,
+ }: React.PropsWithChildren< { className?: string } > ) => (
+ <section className={ className }>{ children }</section>
+ ),
+ Title: ( { children }: React.PropsWithChildren ) => (
+ <h2>{ children }</h2>
+ ),
+ Description: ( {
+ children,
+ className,
+ }: React.PropsWithChildren< { className?: string } > ) => (
+ <p className={ className }>{ children }</p>
+ ),
+ Actions: ( { children }: React.PropsWithChildren ) => (
+ <div>{ children }</div>
+ ),
+ },
+ Stack: ( { children }: React.PropsWithChildren ) => <div>{ children }</div>,
+} ) );
+
+function BrokenComponent(): React.ReactElement {
+ throw new Error( 'Broken component' );
+}
+
+describe( 'AppErrorBoundary', () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach( () => {
+ consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => {} );
+ } );
+
+ afterEach( () => {
+ consoleErrorSpy.mockRestore();
+ } );
+
+ it( 'renders children when there is no error', () => {
+ render(
+ <AppErrorBoundary>
+ <div>Products app</div>
+ </AppErrorBoundary>
+ );
+
+ expect( screen.getByText( 'Products app' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders recovery actions when a child crashes', () => {
+ render(
+ <AppErrorBoundary>
+ <BrokenComponent />
+ </AppErrorBoundary>
+ );
+
+ expect(
+ screen.getByText(
+ 'Oops, the experimental products experience ran into a problem'
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'link', {
+ name: 'Report an issue on GitHub',
+ } )
+ ).toHaveAttribute( 'href', GITHUB_ISSUES_URL );
+ expect(
+ screen.getByRole( 'link', {
+ name: 'Share feedback in survey',
+ } )
+ ).toHaveAttribute( 'href', FEEDBACK_URL );
+ expect(
+ screen.getByRole( 'button', { name: 'Reload page' } )
+ ).toBeInTheDocument();
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/app-error-boundary.tsx b/packages/js/experimental-products-app/src/app-error-boundary.tsx
new file mode 100644
index 00000000000..5e1cc308c6c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/app-error-boundary.tsx
@@ -0,0 +1,104 @@
+/**
+ * External dependencies
+ */
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { __ } from '@wordpress/i18n';
+import { Button } from '@wordpress/components';
+import { EmptyState, Stack } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import { FEEDBACK_URL, GITHUB_ISSUES_URL } from './constants';
+
+type AppErrorBoundaryProps = {
+ children: ReactNode;
+};
+
+type AppErrorBoundaryState = {
+ error: Error | null;
+ hasError: boolean;
+};
+
+export class AppErrorBoundary extends Component<
+ AppErrorBoundaryProps,
+ AppErrorBoundaryState
+> {
+ state: AppErrorBoundaryState = {
+ error: null,
+ hasError: false,
+ };
+
+ static getDerivedStateFromError(
+ error: Error
+ ): Partial< AppErrorBoundaryState > {
+ return {
+ error,
+ hasError: true,
+ };
+ }
+
+ componentDidCatch( error: Error, errorInfo: ErrorInfo ) {
+ // eslint-disable-next-line no-console
+ console.error( error, errorInfo );
+ }
+
+ handleReload = () => {
+ window.location.reload();
+ };
+
+ render() {
+ if ( this.state.hasError ) {
+ return (
+ <EmptyState.Root className="woocommerce-experimental-products-app-error">
+ <EmptyState.Title>
+ { __(
+ 'Oops, the experimental products experience ran into a problem',
+ 'woocommerce'
+ ) }
+ </EmptyState.Title>
+ <EmptyState.Description className="woocommerce-experimental-products-app-error__description">
+ { __(
+ 'This experience is still experimental. Please report the issue on GitHub or share feedback in the survey so we can improve it.',
+ 'woocommerce'
+ ) }
+ </EmptyState.Description>
+ <EmptyState.Actions>
+ <Stack direction="row" gap="xs" justify="center">
+ <Button
+ href={ GITHUB_ISSUES_URL }
+ target="_blank"
+ rel="noopener noreferrer"
+ variant="primary"
+ >
+ { __(
+ 'Report an issue on GitHub',
+ 'woocommerce'
+ ) }
+ </Button>
+ <Button
+ href={ FEEDBACK_URL }
+ target="_blank"
+ rel="noopener noreferrer"
+ variant="secondary"
+ >
+ { __(
+ 'Share feedback in survey',
+ 'woocommerce'
+ ) }
+ </Button>
+ <Button
+ onClick={ this.handleReload }
+ variant="secondary"
+ >
+ { __( 'Reload page', 'woocommerce' ) }
+ </Button>
+ </Stack>
+ </EmptyState.Actions>
+ </EmptyState.Root>
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/packages/js/experimental-products-app/src/constants.ts b/packages/js/experimental-products-app/src/constants.ts
index 8c2b1b338f9..2cf19fbf803 100644
--- a/packages/js/experimental-products-app/src/constants.ts
+++ b/packages/js/experimental-products-app/src/constants.ts
@@ -6,3 +6,7 @@ export const OPERATOR_IS = 'is';
export const OPERATOR_IS_NOT = 'isNot';
export const OPERATOR_IS_ANY = 'isAny';
export const OPERATOR_IS_NONE = 'isNone';
+
+export const FEEDBACK_URL = 'https://usabi.li/do/mqc0hscbzp2i/yejxei';
+export const GITHUB_ISSUES_URL =
+ 'https://github.com/woocommerce/woocommerce/issues/new/choose';
diff --git a/packages/js/experimental-products-app/src/product-list/page/index.tsx b/packages/js/experimental-products-app/src/product-list/page/index.tsx
index 5360b5974b1..803e8b76d42 100644
--- a/packages/js/experimental-products-app/src/product-list/page/index.tsx
+++ b/packages/js/experimental-products-app/src/product-list/page/index.tsx
@@ -5,7 +5,10 @@ import { Page } from '@wordpress/admin-ui';
import { Badge, Stack } from '@wordpress/ui';
import { __ } from '@wordpress/i18n';
-const FEEDBACK_URL = 'https://usabi.li/do/mqc0hscbzp2i/yejxei';
+/**
+ * Internal dependencies
+ */
+import { FEEDBACK_URL } from '../../constants';
type ProductListPageProps = {
ariaLabel: string;
diff --git a/packages/js/experimental-products-app/src/products.tsx b/packages/js/experimental-products-app/src/products.tsx
index 74a38086004..6130635765c 100644
--- a/packages/js/experimental-products-app/src/products.tsx
+++ b/packages/js/experimental-products-app/src/products.tsx
@@ -10,6 +10,7 @@ import {
/**
* Internal dependencies
*/
+import { AppErrorBoundary } from './app-error-boundary';
const ProductsApp = lazy( () =>
import( './app' ).then( ( module ) => ( {
@@ -24,12 +25,21 @@ const ProductsApp = lazy( () =>
*/
export function initializeProductsDashboard( id: string ): Root {
const target = document.getElementById( id );
- const root = createRoot( target! );
+
+ if ( ! target ) {
+ throw new Error(
+ `Could not initialize products dashboard: element with id "${ id }" was not found.`
+ );
+ }
+
+ const root = createRoot( target );
root.render(
<StrictMode>
- <Suspense fallback={ null }>
- <ProductsApp />
- </Suspense>
+ <AppErrorBoundary>
+ <Suspense fallback={ null }>
+ <ProductsApp />
+ </Suspense>
+ </AppErrorBoundary>
</StrictMode>
);
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 811d86ed363..47d872f8a96 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -41,6 +41,16 @@
z-index: 100002;
}
+.woocommerce-experimental-products-app-error {
+ box-sizing: border-box;
+ min-height: calc(100vh - var(--wp-admin--admin-bar--height, 32px));
+ padding: var(--wpds-dimension-padding-2xl, 24px);
+}
+
+.woocommerce-experimental-products-app-error__description {
+ max-width: 520px;
+}
+
.product_page_woocommerce-products-dashboard .edit-site-layout__content {
display: flex;
align-items: stretch;