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;