diff --git a/packages/widget/package.json b/packages/widget/package.json
index c26d1b74..3e095310 100644
--- a/packages/widget/package.json
+++ b/packages/widget/package.json
@@ -91,6 +91,7 @@
"@rolldown/plugin-babel": "catalog:",
"@safe-global/safe-apps-provider": "catalog:",
"@safe-global/safe-apps-sdk": "catalog:",
+ "@sentry/react": "catalog:",
"@solana/wallet-adapter-base": "catalog:",
"@solana/wallet-adapter-react": "catalog:",
"@solana/wallet-adapter-wallets": "catalog:",
diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx
index 8bc4be7c..d26e2979 100644
--- a/packages/widget/src/App.tsx
+++ b/packages/widget/src/App.tsx
@@ -3,17 +3,34 @@ import "./translation";
import "./utils/extend-purify";
import "./styles/theme/global.css";
import type { ComponentProps, RefObject } from "react";
-import { createRef, useImperativeHandle, useState } from "react";
+import {
+ createRef,
+ useEffect,
+ useImperativeHandle,
+ useReducer,
+ useState,
+} from "react";
import ReactDOM from "react-dom/client";
-import { createMemoryRouter, RouterProvider } from "react-router";
+import { I18nextProvider } from "react-i18next";
+import {
+ createMemoryRouter,
+ RouterProvider,
+ useRouteError,
+} from "react-router";
import { preloadImages } from "./assets/images";
import { Box } from "./components/atoms/box";
import { Dashboard } from "./Dashboard";
import { Providers } from "./providers";
+import {
+ captureWidgetException,
+ ErrorTrackingProvider,
+ WidgetErrorDialog,
+} from "./providers/error-tracking";
import { SettingsContextProvider, useSettings } from "./providers/settings";
import type { SettingsProps, VariantProps } from "./providers/settings/types";
+import { ThemeWrapper } from "./providers/theme-wrapper";
import { appContainer } from "./style.css";
-import { useLoadErrorTranslations } from "./translation";
+import { i18nInstance, useLoadErrorTranslations } from "./translation";
import { Widget } from "./Widget";
preloadImages();
@@ -32,6 +49,18 @@ const Root = () => (
);
+const RouteErrorBoundary = ({ onRetry }: { onRetry: () => void }) => {
+ const error = useRouteError();
+
+ useEffect(() => {
+ captureWidgetException(error, {
+ mechanism: "react-router-error-boundary",
+ });
+ }, [error]);
+
+ return ;
+};
+
export type SKAppProps = SettingsProps & (VariantProps | { variant?: never });
export const SKApp = (props: SKAppProps) => {
@@ -40,19 +69,34 @@ export const SKApp = (props: SKAppProps) => {
? { variant: props.variant, chainModal: props.chainModal }
: { variant: props.variant ?? "default" };
+ const [routerVersion, resetRouter] = useReducer((state) => state + 1, 0);
const [router] = useState(() =>
- createMemoryRouter([{ path: "*", Component: Root }])
+ createMemoryRouter([
+ {
+ path: "*",
+ Component: Root,
+ errorElement: (
+
+ ),
+ },
+ ])
);
return (
-
-
-
+
+
+
+
+
+
+
+
+
);
};
@@ -79,7 +123,27 @@ export const renderSKWidget = ({
}) => {
if (!rest.apiKey) throw new Error("API key is required");
- const root = ReactDOM.createRoot(container);
+ const root = ReactDOM.createRoot(container, {
+ onCaughtError: (error, errorInfo) => {
+ captureWidgetException(error, {
+ mechanism: "react-root-caught-error",
+ componentStack: errorInfo.componentStack ?? undefined,
+ });
+ },
+ onRecoverableError: (error, errorInfo) => {
+ captureWidgetException(error, {
+ mechanism: "react-root-recoverable-error",
+ componentStack: errorInfo.componentStack ?? undefined,
+ });
+ },
+ onUncaughtError: (error, errorInfo) => {
+ captureWidgetException(error, {
+ mechanism: "react-root-uncaught-error",
+ componentStack: errorInfo.componentStack ?? undefined,
+ handled: false,
+ });
+ },
+ });
const appRef = createRef<{ rerender: () => void }>() as NonNullable<
BundledSKWidgetProps["ref"]
diff --git a/packages/widget/src/components/molecules/rich-error-modal/index.tsx b/packages/widget/src/components/molecules/rich-error-modal/index.tsx
index 6d8240df..43db83ba 100644
--- a/packages/widget/src/components/molecules/rich-error-modal/index.tsx
+++ b/packages/widget/src/components/molecules/rich-error-modal/index.tsx
@@ -1,16 +1,33 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { images } from "../../../assets/images";
-import { useRichErrors } from "../../../hooks/use-rich-errors";
+import { type RichError, useRichErrors } from "../../../hooks/use-rich-errors";
import { Box } from "../../atoms/box";
+import { Button } from "../../atoms/button";
import { SelectModal } from "../../atoms/select-modal";
import { Heading } from "../../atoms/typography/heading";
import { Text } from "../../atoms/typography/text";
import { imageStyle } from "./style.css";
-export const RichErrorModal = () => {
+type Props = {
+ error?: RichError | null;
+ isOpen?: boolean;
+ onClose?: () => void;
+ action?: { label: string; onClick: () => void };
+ description?: string;
+};
+
+export const RichErrorModal = ({
+ error: controlledError,
+ isOpen,
+ onClose,
+ action,
+ description,
+}: Props = {}) => {
const { i18n, t } = useTranslation();
- const { error, resetError } = useRichErrors();
+ const { error: richError, resetError } = useRichErrors();
+ const error = controlledError === undefined ? richError : controlledError;
+ const close = onClose ?? resetError;
const { message, details } = error ?? {};
const hasKnownMessage = message ? i18n.exists(`errors.${message}`) : false;
@@ -18,8 +35,8 @@ export const RichErrorModal = () => {
return (
!isOpen && resetError() }}
- onClose={resetError}
+ state={{ isOpen: isOpen ?? !!error, setOpen: (open) => !open && close() }}
+ onClose={close}
>
{
>
{!message && (
-
+
{t("shared.something_went_wrong")}
+
+ {description && (
+
+ {description}
+
+ )}
)}
@@ -88,6 +115,16 @@ export const RichErrorModal = () => {
)}
+
+ {action && (
+
+
+
+ )}
);
diff --git a/packages/widget/src/hooks/use-rich-errors.ts b/packages/widget/src/hooks/use-rich-errors.ts
index a761cae7..8def5656 100644
--- a/packages/widget/src/hooks/use-rich-errors.ts
+++ b/packages/widget/src/hooks/use-rich-errors.ts
@@ -2,7 +2,7 @@ import { useCallback, useSyncExternalStore } from "react";
import { BehaviorSubject } from "rxjs";
import { config } from "../config";
-interface RichError {
+export interface RichError {
message: string;
details?: { [key: string]: unknown };
}
diff --git a/packages/widget/src/providers/error-tracking/index.tsx b/packages/widget/src/providers/error-tracking/index.tsx
new file mode 100644
index 00000000..41d388b8
--- /dev/null
+++ b/packages/widget/src/providers/error-tracking/index.tsx
@@ -0,0 +1,290 @@
+import {
+ BrowserClient,
+ defaultStackParser,
+ type ErrorEvent,
+ makeFetchTransport,
+ Scope,
+} from "@sentry/react";
+import {
+ Component,
+ type ErrorInfo,
+ type PropsWithChildren,
+ useEffect,
+} from "react";
+import { useTranslation } from "react-i18next";
+import { RichErrorModal } from "../../components/molecules/rich-error-modal";
+import { config } from "../../config";
+import { useSKLocation } from "../location";
+import { useSettings } from "../settings";
+import { useSKWallet } from "../sk-wallet";
+
+const betterStackDsn =
+ "https://k3dgyiYydYXyFwe7MPsNWk3N@s2546777.us-east-9.betterstackdata.com/2546777";
+
+type WidgetErrorContext = {
+ walletAddress?: string;
+ network?: string;
+ chainId?: number;
+ route?: string;
+ variant?: string;
+ mode?: "dashboard" | "widget";
+};
+
+type CaptureDetails = {
+ mechanism: string;
+ componentStack?: string;
+ handled?: boolean;
+};
+
+type ErrorTrackingState = {
+ client: BrowserClient;
+ scope: Scope;
+};
+
+let errorTrackingState: ErrorTrackingState | null = null;
+let latestWidgetContext: WidgetErrorContext = {};
+const capturedErrorObjects = new WeakSet