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
30 changes: 23 additions & 7 deletions app/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { AuthDemo } from './live/AuthDemo'
import { PingPongDemo } from './live/PingPongDemo'
import { AppLayout } from './components/AppLayout'
import { DemoPage } from './components/DemoPage'
import { ErrorBoundary } from './components/ErrorBoundary'
import { LiveErrorBoundary } from './components/LiveErrorBoundary'
import { HomePage } from './pages/HomePage'
import { ApiTestPage } from './pages/ApiTestPage'

Expand Down Expand Up @@ -90,23 +92,29 @@ function AppContent() {
<DemoPage
note={<>? Este formul?rio usa <code className="text-purple-400">Live.use()</code> - cada campo sincroniza automaticamente com o servidor!</>}
>
<FormDemo />
<LiveErrorBoundary>
<FormDemo />
</LiveErrorBoundary>
</DemoPage>
}
/>
<Route
path="/counter"
element={
<DemoPage>
<CounterDemo />
<LiveErrorBoundary>
<CounterDemo />
</LiveErrorBoundary>
</DemoPage>
}
/>
<Route
path="/upload"
element={
<DemoPage>
<UploadDemo />
<LiveErrorBoundary>
<UploadDemo />
</LiveErrorBoundary>
</DemoPage>
}
/>
Expand All @@ -116,7 +124,9 @@ function AppContent() {
<DemoPage
note={<>Contador compartilhado usando <code className="text-purple-400">LiveRoom</code> - abra em varias abas!</>}
>
<SharedCounterDemo />
<LiveErrorBoundary>
<SharedCounterDemo />
</LiveErrorBoundary>
</DemoPage>
}
/>
Expand All @@ -126,7 +136,9 @@ function AppContent() {
<DemoPage
note={<>Chat com múltiplas salas usando o sistema <code className="text-purple-400">$room</code>.</>}
>
<RoomChatDemo />
<LiveErrorBoundary>
<RoomChatDemo />
</LiveErrorBoundary>
</DemoPage>
}
/>
Expand All @@ -136,7 +148,9 @@ function AppContent() {
<DemoPage
note={<>🔒 Sistema de autenticação declarativo para Live Components com <code className="text-purple-400">$auth</code>!</>}
>
<AuthDemo />
<LiveErrorBoundary>
<AuthDemo />
</LiveErrorBoundary>
</DemoPage>
}
/>
Expand All @@ -146,7 +160,9 @@ function AppContent() {
<DemoPage
note={<>Latency demo com <code className="text-cyan-400">msgpack</code> binary codec - mensagens binárias no WebSocket!</>}
>
<PingPongDemo />
<LiveErrorBoundary>
<PingPongDemo />
</LiveErrorBoundary>
</DemoPage>
}
/>
Expand Down
75 changes: 75 additions & 0 deletions app/client/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'

interface ErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
onError?: (error: Error, errorInfo: ErrorInfo) => void
}

interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null }

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[FluxStack] Component error:', error, errorInfo.componentStack)
this.props.onError?.(error, errorInfo)
}

reset = () => {
this.setState({ hasError: false, error: null })
}

render() {
if (this.state.hasError && this.state.error) {
if (typeof this.props.fallback === 'function') {
return this.props.fallback(this.state.error, this.reset)
}
if (this.props.fallback) {
return this.props.fallback
}
return <DefaultErrorFallback error={this.state.error} onReset={this.reset} />
}
return this.props.children
}
}

function DefaultErrorFallback({ error, onReset }: { error: Error; onReset: () => void }) {
const isDev = import.meta.env.DEV

return (
<div className="flex items-center justify-center p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 sm:p-8 max-w-lg w-full text-center">
<div className="text-4xl mb-4">!</div>
<h2 className="text-xl font-bold text-red-300 mb-2">Something went wrong</h2>
<p className="text-gray-400 text-sm mb-4">
An unexpected error occurred while rendering this component.
</p>
{isDev && (
<details className="text-left mb-4">
<summary className="text-red-400 text-xs cursor-pointer hover:text-red-300 transition-colors">
Error details
</summary>
<pre className="mt-2 p-3 bg-black/30 rounded-lg text-red-300 text-xs overflow-auto max-h-40">
{error.message}
{error.stack && `\n\n${error.stack}`}
</pre>
</details>
)}
<button
onClick={onReset}
className="px-5 py-2 bg-red-500/20 hover:bg-red-500/30 border border-red-500/30 text-red-300 rounded-xl text-sm transition-all"
>
Try again
</button>
</div>
</div>
)
}
37 changes: 37 additions & 0 deletions app/client/src/components/LiveErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import { ErrorBoundary } from './ErrorBoundary'

function LiveErrorFallback({ error, onReset }: { error: Error; onReset: () => void }) {
const isDev = import.meta.env.DEV

return (
<div className="bg-amber-500/10 border border-amber-500/20 rounded-2xl p-5 sm:p-8 max-w-md w-full text-center">
<div className="text-3xl mb-3">~</div>
<h3 className="text-lg font-bold text-amber-300 mb-2">Live Component Error</h3>
<p className="text-gray-400 text-sm mb-4">
This real-time component encountered an error. The connection may have been lost.
</p>
{isDev && (
<pre className="mb-4 p-3 bg-black/30 rounded-lg text-amber-300 text-xs overflow-auto max-h-32 text-left">
{error.message}
</pre>
)}
<button
onClick={onReset}
className="px-5 py-2 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/30 text-amber-300 rounded-xl text-sm transition-all"
>
Reconnect
</button>
</div>
)
}

export function LiveErrorBoundary({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
fallback={(error, reset) => <LiveErrorFallback error={error} onReset={reset} />}
>
{children}
</ErrorBoundary>
)
}
9 changes: 6 additions & 3 deletions app/client/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { ErrorBoundary } from './components/ErrorBoundary'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<ErrorBoundary>
<BrowserRouter>
<App />
</BrowserRouter>
</ErrorBoundary>
</StrictMode>,
)
135 changes: 135 additions & 0 deletions tests/unit/app/client/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ErrorBoundary } from '@/app/client/src/components/ErrorBoundary'
import { LiveErrorBoundary } from '@/app/client/src/components/LiveErrorBoundary'

// Suppress React error boundary console.error noise in tests
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})

function ThrowingComponent({ message }: { message: string }): never {
throw new Error(message)
}

function GoodComponent() {
return <div>Working fine</div>
}

describe('ErrorBoundary', () => {
it('should render children when no error', () => {
render(
<ErrorBoundary>
<GoodComponent />
</ErrorBoundary>
)
expect(screen.getByText('Working fine')).toBeTruthy()
})

it('should render default fallback on error', () => {
render(
<ErrorBoundary>
<ThrowingComponent message="Test crash" />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeTruthy()
expect(screen.getByText('Try again')).toBeTruthy()
})

it('should render custom ReactNode fallback', () => {
render(
<ErrorBoundary fallback={<div>Custom fallback</div>}>
<ThrowingComponent message="Test crash" />
</ErrorBoundary>
)
expect(screen.getByText('Custom fallback')).toBeTruthy()
})

it('should render custom function fallback with error and reset', () => {
render(
<ErrorBoundary
fallback={(error, reset) => (
<div>
<span>Error: {error.message}</span>
<button onClick={reset}>Reset</button>
</div>
)}
>
<ThrowingComponent message="Specific error" />
</ErrorBoundary>
)
expect(screen.getByText('Error: Specific error')).toBeTruthy()
expect(screen.getByText('Reset')).toBeTruthy()
})

it('should call onError callback when error occurs', () => {
const onError = vi.fn()
render(
<ErrorBoundary onError={onError}>
<ThrowingComponent message="Callback test" />
</ErrorBoundary>
)
expect(onError).toHaveBeenCalledTimes(1)
expect(onError.mock.calls[0][0].message).toBe('Callback test')
})

it('should reset and re-render children on Try again click', () => {
let shouldThrow = true
function MaybeThrow() {
if (shouldThrow) throw new Error('Temporary error')
return <div>Recovered</div>
}

render(
<ErrorBoundary>
<MaybeThrow />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeTruthy()

shouldThrow = false
fireEvent.click(screen.getByText('Try again'))
expect(screen.getByText('Recovered')).toBeTruthy()
})
})

describe('LiveErrorBoundary', () => {
it('should render children when no error', () => {
render(
<LiveErrorBoundary>
<GoodComponent />
</LiveErrorBoundary>
)
expect(screen.getByText('Working fine')).toBeTruthy()
})

it('should render live-specific fallback on error', () => {
render(
<LiveErrorBoundary>
<ThrowingComponent message="WS crash" />
</LiveErrorBoundary>
)
expect(screen.getByText('Live Component Error')).toBeTruthy()
expect(screen.getByText('Reconnect')).toBeTruthy()
})

it('should recover after clicking Reconnect', () => {
let shouldThrow = true
function MaybeLiveThrow() {
if (shouldThrow) throw new Error('Connection lost')
return <div>Reconnected</div>
}

render(
<LiveErrorBoundary>
<MaybeLiveThrow />
</LiveErrorBoundary>
)
expect(screen.getByText('Live Component Error')).toBeTruthy()

shouldThrow = false
fireEvent.click(screen.getByText('Reconnect'))
expect(screen.getByText('Reconnected')).toBeTruthy()
})
})
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
Expand Down
Loading