Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
88 changes: 76 additions & 12 deletions packages/widget/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -32,6 +49,18 @@ const Root = () => (
</Providers>
);

const RouteErrorBoundary = ({ onRetry }: { onRetry: () => void }) => {
const error = useRouteError();

useEffect(() => {
captureWidgetException(error, {
mechanism: "react-router-error-boundary",
});
}, [error]);

return <WidgetErrorDialog onRetry={onRetry} />;
};

export type SKAppProps = SettingsProps & (VariantProps | { variant?: never });

export const SKApp = (props: SKAppProps) => {
Expand All @@ -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: (
<RouteErrorBoundary key={routerVersion} onRetry={resetRouter} />
),
},
])
);
Comment on lines +72 to 83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If the repo includes lockfiles or dependency metadata, inspect the exact React Router version.
git ls-files | rg '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|package.json)$'

Repository: stakekit/widget

Length of output: 390


Recreate the router when retrying route errors. routerVersion only affects the errorElement captured by the initial createMemoryRouter call, so resetRouter never updates the router instance or clears its stored error state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widget/src/App.tsx` around lines 72 - 83, Recreate the router on
retry instead of only remounting RouteErrorBoundary: the current routerVersion
state is captured inside the initial createMemoryRouter call in App, so
resetRouter does not replace the router or clear its error state. Update App so
the router instance is rebuilt when routerVersion changes, ensuring onRetry from
RouteErrorBoundary triggers a fresh memory router with cleared errors.


return (
<SettingsContextProvider {...variantProps} {...props}>
<Box
className={appContainer({
variant: props.dashboardVariant ? "dashboard" : "widget",
})}
>
<RouterProvider router={router} />
</Box>
<I18nextProvider i18n={i18nInstance}>
<ThemeWrapper>
<Box
className={appContainer({
variant: props.dashboardVariant ? "dashboard" : "widget",
})}
>
<ErrorTrackingProvider>
<RouterProvider router={router} key={routerVersion} />
</ErrorTrackingProvider>
</Box>
</ThemeWrapper>
</I18nextProvider>
</SettingsContextProvider>
);
};
Expand All @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
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;

useEffect(() => resetError, [resetError]);

return (
<SelectModal
state={{ isOpen: !!error, setOpen: (isOpen) => !isOpen && resetError() }}
onClose={resetError}
state={{ isOpen: isOpen ?? !!error, setOpen: (open) => !open && close() }}
onClose={close}
>
<Box
display="flex"
Expand All @@ -32,10 +49,20 @@ export const RichErrorModal = () => {
>
<Box as="img" src={images.whatIsLiquidStaking} className={imageStyle} />
{!message && (
<Box marginBottom="6">
<Box marginBottom="6" textAlign="center">
<Heading variant={{ level: "h4" }}>
{t("shared.something_went_wrong")}
</Heading>

{description && (
<Text
variant={{ type: "muted", weight: "normal" }}
textAlign="center"
marginTop="2"
>
{description}
</Text>
)}
</Box>
)}

Expand Down Expand Up @@ -88,6 +115,16 @@ export const RichErrorModal = () => {
</Text>
</Box>
)}

{action && (
<Box marginTop="6" width="full">
<Button variant={{ color: "secondary" }} onClick={action.onClick}>
<Text variant={{ weight: "bold", size: "large" }}>
{action.label}
</Text>
</Button>
</Box>
)}
</Box>
</SelectModal>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/widget/src/hooks/use-rich-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
Loading