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
16 changes: 16 additions & 0 deletions docs/dark-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` in `index.html` reads `credence:settings` from localStorage
and sets `document.documentElement.setAttribute('data-theme', …)` synchronously
before React mounts. This prevents a flash-of-unstyled-content (FOUC) when the
user has a dark or system preference saved.

- The script is self-contained (no dependencies) and runs before any module
scripts or stylesheets load.
- It reads the same `credence:settings` key that `SettingsContext` writes.
- It resolves `'system'` via `matchMedia('(prefers-color-scheme: dark)')`
inline — the same logic `SettingsContext` uses — so the first paint matches
the OS preference without waiting for the React tree.
- Failures (missing key, parse error) are silently caught; no theme attribute is
set, and the default light-theme CSS variables apply until React hydrates.

- **State + persistence**: `themeMode` (`'light' | 'dark' | 'system'`) lives in
`SettingsContext` and is persisted under the single `credence:settings`
localStorage key. There is no separate `'theme'` key.
Expand Down
14 changes: 14 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Credence — on-chain economic identity on Stellar. Stake USDC as a programmable reputation bond." />
<title>Credence — Economic Trust</title>
<script>
(function() {
try {
var saved = JSON.parse(localStorage.getItem('credence:settings'));
if (saved && saved.themeMode) {
var theme = saved.themeMode;
if (theme === 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
}
} catch(e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
21 changes: 3 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -41,7 +40,7 @@ function App() {
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Layout />} errorElement={<RouteErrorPage />}>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="bond" element={<Bond />} />
Expand Down
209 changes: 68 additions & 141 deletions src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -1,170 +1,97 @@
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 <div>Normal content</div>
}

function BadChild({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error('test render error')
return <div>Recovered content</div>
}

describe('ErrorBoundary', () => {
beforeEach(() => {
// Suppress React's console.error noise for expected thrown errors.
vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('happy path — no error', () => {
it('renders children when nothing throws', () => {
render(
<ErrorBoundary>
<GoodChild />
</ErrorBoundary>
)
expect(screen.getByText('Normal content')).toBeInTheDocument()
})

it('does not render the fallback when nothing throws', () => {
render(
<ErrorBoundary>
<GoodChild />
</ErrorBoundary>
)
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(
<ErrorBoundary>
<FailChild />
</ErrorBoundary>
)

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(
<ErrorBoundary>
<BadChild shouldThrow />
</ErrorBoundary>
)
it('ErrorBoundary catches lazy-loaded component mount errors and shows error state', async () => {
const LazyFailComponent = () => {
throw new Error('Component render failure')
}

render(
<ErrorBoundary>
<LazyFailComponent />
</ErrorBoundary>
)

await waitFor(() => {
expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument()
})

it('hides children content after a throw', () => {
render(
<ErrorBoundary>
<BadChild shouldThrow />
</ErrorBoundary>
)
expect(screen.queryByText('Normal content')).toBeNull()
})

it('renders a "Try again" button', () => {
render(
<ErrorBoundary>
<BadChild shouldThrow />
</ErrorBoundary>
)
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument()
})

it('renders a "Go to home page" link', () => {
render(
<ErrorBoundary>
<BadChild shouldThrow />
</ErrorBoundary>
)
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(
<ErrorBoundary>
<BadChild shouldThrow />
</ErrorBoundary>
)
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 <div>Recovered content</div>
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(
<ErrorBoundary>
<MaybeThrow />
</ErrorBoundary>
)

return <div>Recovered content</div>
}

render(
<ErrorBoundary>
<FlakyComponent />
</ErrorBoundary>
)

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(
<ErrorBoundary>
<BadChild shouldThrow />
</ErrorBoundary>
)

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) => (
<div>Custom fallback UI</div>
))

render(
<ErrorBoundary fallback={customFallback}>
<BadChild shouldThrow />
</ErrorBoundary>
)

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 <div>Custom recovered</div>
}

render(
<ErrorBoundary fallback={(_err, reset) => <button onClick={reset}>Custom retry</button>}>
<MaybeThrow />
render(
<MemoryRouter>
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<LazyFail />} />
</Routes>
</Suspense>
</ErrorBoundary>
)

expect(screen.getByRole('button', { name: /custom retry/i })).toBeInTheDocument()

shouldThrow = false
fireEvent.click(screen.getByRole('button', { name: /custom retry/i }))
</MemoryRouter>
)

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()
})
})
})
Loading