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( - }> - + 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({ )} 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