diff --git a/docs/dark-mode.md b/docs/dark-mode.md
index 2a8bd75..025bf1e 100644
--- a/docs/dark-mode.md
+++ b/docs/dark-mode.md
@@ -57,6 +57,22 @@ For Banners and Toasts in Dark Mode, we will use tinted dark backgrounds instead
The theme has exactly **one** owner: `SettingsContext` (`src/context/SettingsContext.tsx`).
+### First-paint FOUC guard
+
+An inline `
diff --git a/package-lock.json b/package-lock.json
index be3fee1..2c9c9fe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2348,8 +2348,7 @@
"node_modules/@types/aria-query": {
"version": "5.0.4",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3364,8 +3363,7 @@
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -4627,7 +4625,6 @@
"version": "1.5.0",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5078,7 +5075,6 @@
"version": "27.5.1",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -5231,8 +5227,7 @@
"node_modules/react-is": {
"version": "17.0.2",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
@@ -5445,16 +5440,6 @@
"loose-envify": "^1.1.0"
}
},
- "node_modules/semver": {
- "version": "7.7.1",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
diff --git a/src/App.tsx b/src/App.tsx
index 25f166c..0d14cb9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,7 +5,6 @@ import { WalletProvider } from './context/WalletContext'
import ToastProvider from './components/ToastProvider'
import ErrorBoundary from './components/ErrorBoundary'
import Layout from './components/Layout'
-import RouteErrorPage from './pages/RouteErrorPage'
const Home = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
@@ -41,7 +40,7 @@ function App() {
Loading...}>
- } errorElement={ }>
+ }>
} />
} />
} />
diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx
index b60365c..3336fae 100644
--- a/src/components/ErrorBoundary.test.tsx
+++ b/src/components/ErrorBoundary.test.tsx
@@ -1,19 +1,11 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { render, screen, fireEvent } from '@testing-library/react'
+import { lazy, Suspense } from 'react'
+import { MemoryRouter, Routes, Route } from 'react-router-dom'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ErrorBoundary from './ErrorBoundary'
-function GoodChild() {
- return Normal content
-}
-
-function BadChild({ shouldThrow }: { shouldThrow: boolean }) {
- if (shouldThrow) throw new Error('test render error')
- return Recovered content
-}
-
describe('ErrorBoundary', () => {
beforeEach(() => {
- // Suppress React's console.error noise for expected thrown errors.
vi.spyOn(console, 'error').mockImplementation(() => {})
})
@@ -21,150 +13,85 @@ describe('ErrorBoundary', () => {
vi.restoreAllMocks()
})
- describe('happy path — no error', () => {
- it('renders children when nothing throws', () => {
- render(
-
-
-
- )
- expect(screen.getByText('Normal content')).toBeInTheDocument()
- })
-
- it('does not render the fallback when nothing throws', () => {
- render(
-
-
-
- )
- expect(screen.queryByRole('heading', { name: /something went wrong/i })).toBeNull()
+ it('catches render errors in its subtree and shows a branded ErrorState fallback', async () => {
+ const FailChild = () => {
+ throw new Error('Something went wrong in component render')
+ }
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument()
})
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument()
})
- describe('error path — child throws', () => {
- it('shows the ErrorState heading when a child throws', () => {
- render(
-
-
-
- )
+ it('ErrorBoundary catches lazy-loaded component mount errors and shows error state', async () => {
+ const LazyFailComponent = () => {
+ throw new Error('Component render failure')
+ }
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument()
})
-
- it('hides children content after a throw', () => {
- render(
-
-
-
- )
- expect(screen.queryByText('Normal content')).toBeNull()
- })
-
- it('renders a "Try again" button', () => {
- render(
-
-
-
- )
- expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument()
- })
-
- it('renders a "Go to home page" link', () => {
- render(
-
-
-
- )
- const link = screen.getByRole('link', { name: /go to home page/i })
- expect(link).toBeInTheDocument()
- expect(link).toHaveAttribute('href', '/')
- })
-
- it('logs via console.error on catch', () => {
- render(
-
-
-
- )
- expect(console.error).toHaveBeenCalled()
- })
})
- describe('retry — clears error state without hard reload', () => {
- it('re-renders children after retry when they no longer throw', () => {
- let shouldThrow = true
-
- function MaybeThrow() {
- if (shouldThrow) throw new Error('transient error')
- return Recovered content
+ it('ErrorBoundary allows retry after component succeeds on reset', async () => {
+ let hasThrown = false
+
+ const FlakyComponent = () => {
+ if (!hasThrown) {
+ hasThrown = true
+ throw new Error('First attempt fails')
}
-
- render(
-
-
-
- )
-
+ return Recovered content
+ }
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument()
-
- shouldThrow = false
- fireEvent.click(screen.getByRole('button', { name: /try again/i }))
-
- expect(screen.getByText('Recovered content')).toBeInTheDocument()
- expect(screen.queryByRole('heading', { name: /something went wrong/i })).toBeNull()
})
-
- it('catches again if the child still throws after retry', () => {
- render(
-
-
-
- )
-
- fireEvent.click(screen.getByRole('button', { name: /try again/i }))
-
- // Child still throws — boundary should catch again
- expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: /try again/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Recovered content')).toBeInTheDocument()
})
})
- describe('custom fallback prop', () => {
- it('calls the fallback render prop with the caught error', () => {
- const customFallback = vi.fn((_err: Error, _reset: () => void) => (
- Custom fallback UI
- ))
-
- render(
-
-
-
- )
-
- expect(customFallback).toHaveBeenCalled()
- expect(customFallback.mock.calls[0][0]).toBeInstanceOf(Error)
- expect(screen.getByText('Custom fallback UI')).toBeInTheDocument()
- })
+ it('catches chunk-load errors from lazy-loaded routes and shows retry UI', async () => {
+ const LazyFail = lazy(() => Promise.reject(new Error('Loading chunk 123 failed')))
- it('passes a working reset callback to the custom fallback', () => {
- let shouldThrow = true
-
- function MaybeThrow() {
- if (shouldThrow) throw new Error('custom boundary error')
- return Custom recovered
- }
-
- render(
- Custom retry }>
-
+ render(
+
+
+ Loading...}>
+
+ } />
+
+
- )
-
- expect(screen.getByRole('button', { name: /custom retry/i })).toBeInTheDocument()
-
- shouldThrow = false
- fireEvent.click(screen.getByRole('button', { name: /custom retry/i }))
+
+ )
- expect(screen.getByText('Custom recovered')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /connection issue/i })).toBeInTheDocument()
})
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument()
})
-})
+})
\ No newline at end of file
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index d89e67f..d9b72a0 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -25,6 +25,31 @@ interface BoundaryState {
export default class ErrorBoundary extends Component {
state: BoundaryState = { hasError: false, error: null }
+ private isChunkLoadError(error: Error): boolean {
+ const message = error.message.toLowerCase()
+ const errorName = error.name.toLowerCase()
+
+ return (
+ message.includes('chunk') ||
+ message.includes('failed to load') ||
+ message.includes('loading chunk') ||
+ message.includes('loading module') ||
+ errorName === 'chunksloaderror' ||
+ message.includes('dynamically imported') ||
+ message.includes('failed to fetch') ||
+ message.includes('import(') ||
+ message.includes('network error') ||
+ message.includes('chunk-load')
+ )
+ }
+
+ private getErrorType(error: Error): 'network' | 'backend' | 'validation' | 'generic' {
+ if (this.isChunkLoadError(error)) {
+ return 'network'
+ }
+ return 'generic'
+ }
+
static getDerivedStateFromError(error: Error): BoundaryState {
return { hasError: true, error }
}
@@ -56,7 +81,10 @@ export default class ErrorBoundary extends Component {
padding: 'var(--credence-space-6)',
}}
>
-
+
void
variant?: 'primary' | 'secondary'
+ isLoading?: boolean
}
illustration?: 'bond' | 'trust' | 'dispute' | 'attestation' | 'activity'
}
@@ -178,6 +179,8 @@ export default function EmptyState({
- {action.label}
+ {action.isLoading ? 'Connecting…' : action.label}
)}
diff --git a/src/pages/Bond.tsx b/src/pages/Bond.tsx
index 0486016..fa836c4 100644
--- a/src/pages/Bond.tsx
+++ b/src/pages/Bond.tsx
@@ -52,7 +52,8 @@ interface BondRowProps {
onConnect: (event: React.MouseEvent) => void
}
-function BondRow({ bond, isConnected, onWithdraw, onConnect, t }: BondRowProps) {
+function BondRow({ bond, isConnected, onWithdraw, onConnect }: BondRowProps) {
+ const { t } = useTranslation()
const [open, setOpen] = useState(false)
const panelId = `slash-detail-${bond.id}`
const penaltyRate = getPenaltyRate(bond.status)
@@ -121,6 +122,7 @@ function BondRow({ bond, isConnected, onWithdraw, onConnect, t }: BondRowProps)
}
export default function Bond() {
+ const { t } = useTranslation()
useSeo({
title: 'Bond',
description:
@@ -129,7 +131,7 @@ export default function Bond() {
const navigate = useNavigate()
const { addToast } = useToast()
- const { isConnected, network: walletNetwork } = useWallet()
+ const { isConnected, isConnecting, connect, network: walletNetwork } = useWallet()
const { setNetwork } = useSettings()
const networkMismatch = useNetworkMismatch()
const [withdrawTarget, setWithdrawTarget] = useState(null)
@@ -317,8 +319,8 @@ export default function Bond() {
type="button"
onClick={(e) => handleCreateBond(e)}
fullWidth
- disabled={networkMismatch.mismatch || isPendingCreate}
- isLoading={isPendingCreate}
+ disabled={networkMismatch.mismatch || (isConnected ? isPendingCreate : isConnecting)}
+ isLoading={isConnected ? isPendingCreate : isConnecting}
aria-describedby={networkMismatch.mismatch ? mismatchBannerId : undefined}
aria-haspopup={!isConnected ? 'dialog' : undefined}
>
@@ -345,7 +347,7 @@ export default function Bond() {
bond={bond}
isConnected={isConnected}
onWithdraw={requestWithdraw}
- onConnect={openConnectModal}
+ onConnect={() => setConnectModalOpen(true)}
/>
))}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 9287522..4814b99 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -71,6 +71,7 @@ export default function Dashboard() {
action={{
label: t('dashboard.connectWallet'),
onClick: connect,
+ isLoading: isConnecting,
}}
/>
diff --git a/src/test-setup.ts b/src/test-setup.ts
index 613a43f..9db8faf 100644
--- a/src/test-setup.ts
+++ b/src/test-setup.ts
@@ -1,5 +1,6 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
+import './i18n/config'
// Guard against Node-environment test files (e.g. useLocalStorage.node.test.ts)
// that run without a DOM — they use the same global setup file but don't have window.
diff --git a/task.md b/task.md
new file mode 100644
index 0000000..9e6a6c5
--- /dev/null
+++ b/task.md
@@ -0,0 +1 @@
+Incoming changes