diff --git a/agents/react-native-engineer.md b/agents/react-native-engineer.md index 55a41845..249a17ce 100644 --- a/agents/react-native-engineer.md +++ b/agents/react-native-engineer.md @@ -80,6 +80,8 @@ Do not load references for domains not relevant to the task — context is a sca | useState, derived state, Zustand, state structure, dispatchers, ground truth | `state-management.md` | | Conditional rendering, &&, Text components, React Compiler, memoization | `rendering-patterns.md` | | Monorepo, fonts, imports, design system, dependency versions, autolinking | `monorepo-config.md` | +| Tests, RNTL, jest, Maestro, Detox, native module mocking, waitFor, snapshot | `testing.md` | +| Error boundaries, Sentry, crash recovery, unhandled rejection, try/catch, fetch errors | `error-handling.md` | ## Error Handling @@ -93,6 +95,10 @@ Do not load references for domains not relevant to the task — context is a sca **State sync issues**: Load `state-management.md` — stale closure or redundant derived state. +**Production crashes, Error Boundaries, Sentry, unhandled rejections**: Load `error-handling.md` — error boundary setup, crash reporting patterns, fetch error handling. + +**Test setup, RNTL queries, native module mocks, async assertions**: Load `testing.md` — RNTL patterns, jest config, native mock setup, anti-patterns. + ## References - [list-performance.md](react-native-engineer/references/list-performance.md) — FlashList/LegendList, memoization, virtualization, stable references @@ -102,3 +108,5 @@ Do not load references for domains not relevant to the task — context is a sca - [state-management.md](react-native-engineer/references/state-management.md) — Minimal state, dispatch updaters, fallback patterns, ground truth - [rendering-patterns.md](react-native-engineer/references/rendering-patterns.md) — Falsy && crash prevention, Text components, React Compiler - [monorepo-config.md](react-native-engineer/references/monorepo-config.md) — Fonts, imports, native dep autolinking, dependency versions +- [testing.md](react-native-engineer/references/testing.md) — RNTL patterns, jest config, native module mocking, async assertions, anti-patterns +- [error-handling.md](react-native-engineer/references/error-handling.md) — Error boundaries, Sentry init, unhandled rejections, fetch error handling, crash recovery diff --git a/agents/react-native-engineer/references/error-handling.md b/agents/react-native-engineer/references/error-handling.md new file mode 100644 index 00000000..5c2bdc29 --- /dev/null +++ b/agents/react-native-engineer/references/error-handling.md @@ -0,0 +1,332 @@ +# Error Handling Reference + + +> **Scope**: Production error handling in React Native: Error Boundaries, Sentry integration, promise rejection capture, and crash-safe rendering patterns. +> **Version range**: React 18+, React Native 0.72+, @sentry/react-native 5+ +> **Generated**: 2026-04-12 — verify Sentry DSN config patterns against current @sentry/react-native docs + +--- + +## Overview + +React Native apps crash hard on unhandled errors — there's no browser error overlay to recover from. A production crash closes the app. The three failure modes are: (1) synchronous render errors without an `ErrorBoundary`, (2) unhandled promise rejections that swallow failures silently, and (3) native module errors that surface as cryptic red boxes during development but silent crashes in production builds. + +--- + +## Pattern Table + +| Pattern | Version | Use When | Avoid When | +|---------|---------|----------|------------| +| `ErrorBoundary` (class component) | React 16+ | catching render-phase errors | async errors inside event handlers | +| `react-native-error-boundary` | any | quick ErrorBoundary with fallback UI | you need custom recovery logic | +| `Sentry.init()` in app entry | `@sentry/react-native 5+` | production crash reporting | local dev — noise ratio is high | +| `unhandledRejection` global handler | RN 0.68+ | catching all unhandled promise rejections | replacing proper `try/catch` per call | +| `InteractionManager.runAfterInteractions` | any | deferring error-prone work past animation frames | time-sensitive data fetching | + +--- + +## Correct Patterns + +### Wrap Screen Roots in ErrorBoundary + +Every screen-level component should be wrapped in an `ErrorBoundary`. A crash in one screen should not bring down the entire app. + +```tsx +import { ErrorBoundary } from 'react-error-boundary' + +function FeedScreen() { + return ( + ( + + Something went wrong. + + Try again + + + )} + onError={(error, info) => { + // report to Sentry or your crash service + captureException(error, { extra: { componentStack: info.componentStack } }) + }} + > + + + ) +} +``` + +**Why**: Without a boundary, any render-phase throw (null dereference, bad prop type, failed deserialization) crashes the entire React tree. The boundary catches it, renders fallback UI, and lets the user recover without restarting the app. + +--- + +### Initialize Sentry Before the React Tree + +Sentry must be initialized before `AppRegistry.registerComponent` — before any React component mounts. Errors during app startup are otherwise invisible. + +```ts +// index.js (app entry — before importing App) +import * as Sentry from '@sentry/react-native' + +Sentry.init({ + dsn: process.env.EXPO_PUBLIC_SENTRY_DSN, + environment: process.env.EXPO_PUBLIC_ENV ?? 'development', + // Sample at 10% in production to control event volume + tracesSampleRate: process.env.EXPO_PUBLIC_ENV === 'production' ? 0.1 : 1.0, + enabled: process.env.EXPO_PUBLIC_ENV !== 'development', +}) + +// Then import and register App +import { registerRootComponent } from 'expo' +import App from './App' +registerRootComponent(App) +``` + +**Why**: Errors that occur during app initialization (config load, font loading, initial navigation mount) are lost if Sentry isn't set up first. `EXPO_PUBLIC_*` variables are safe to embed in the bundle — do not use secret keys here. + +--- + +### Capture Unhandled Promise Rejections + +React Native 0.68+ surfaces unhandled rejections as yellow warnings in dev, but in production they silently swallow errors. Install a global handler. + +```ts +// App.tsx or index.js setup +const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + console.error('Unhandled promise rejection:', event.reason) + captureException(event.reason, { tags: { type: 'unhandled_rejection' } }) + // Don't call event.preventDefault() — let RN's default handling also run +} + +// Node-style (Hermes engine, RN 0.64+) +if (global.HermesInternal) { + const tracking = require('promise/setimmediate/rejection-tracking') + tracking.enable({ + allRejections: true, + onUnhandled: (id: number, rejection: unknown) => { + captureException(rejection, { tags: { rejection_id: id } }) + }, + }) +} +``` + +**Why**: Fire-and-forget async calls (`fetchUser()` without `await` or `.catch()`) fail silently in production. The global handler catches them for visibility without requiring every call site to add error handling. + +--- + +### Type Fetch Errors — Never Assume the Shape + +Network errors in React Native come in three shapes: `Error` instances from `fetch` throwing on network failure, JSON parse errors when the server returns HTML (503 page), and valid JSON with an error status code. + +```ts +async function fetchUser(id: string): Promise { + let res: Response + + try { + res = await fetch(`${API_URL}/users/${id}`) + } catch (err) { + // Network failure — no response at all + throw new Error(`Network error fetching user ${id}: ${String(err)}`) + } + + if (!res.ok) { + // Server returned 4xx/5xx — body may not be JSON + const text = await res.text().catch(() => '') + throw new Error(`HTTP ${res.status} fetching user ${id}: ${text.slice(0, 200)}`) + } + + try { + return res.json() as Promise + } catch (err) { + throw new Error(`Invalid JSON for user ${id}: ${String(err)}`) + } +} +``` + +**Why**: `fetch` does NOT throw on 4xx/5xx status codes. Calling `res.json()` on a 503 HTML error page throws a parse error with a misleading message. Wrapping each phase separately gives actionable error messages in Sentry. + +--- + +## Anti-Pattern Catalog + +### ❌ Using `console.error` as the Only Error Reporting + +**Detection**: +```bash +grep -rn 'console\.error' --include="*.tsx" --include="*.ts" | grep -v "\.test\." | grep -v "\.spec\." +rg 'console\.error' --type ts --type tsx | grep -v test +``` + +**What it looks like**: +```tsx +try { + await syncData() +} catch (err) { + console.error('Sync failed', err) // invisible in production +} +``` + +**Why wrong**: `console.error` is stripped or suppressed in production builds. Errors logged this way are invisible to on-call and never trigger alerts. Crashes go undetected until users report them. + +**Fix**: +```tsx +import { captureException } from '@sentry/react-native' + +try { + await syncData() +} catch (err) { + captureException(err, { tags: { operation: 'sync' } }) + // optionally also console.error in dev + if (__DEV__) console.error('Sync failed', err) +} +``` + +--- + +### ❌ Empty Catch Blocks + +**Detection**: +```bash +grep -rn 'catch\s*(.*)\s*{\s*}' --include="*.ts" --include="*.tsx" +rg 'catch\s*\(.*\)\s*\{\s*\}' --type ts +``` + +**What it looks like**: +```ts +try { + await loadUserPreferences() +} catch (err) { + // TODO: handle this +} +``` + +**Why wrong**: Silent swallow. The error is gone. The app is now in an inconsistent state — preferences were not loaded, but no error boundary fired, no fallback rendered, no alert triggered. These are the hardest bugs to diagnose because there's no stack trace. + +**Fix**: At minimum, report and reset to a safe default: +```ts +try { + await loadUserPreferences() +} catch (err) { + captureException(err) + // explicit fallback state + await setDefaultPreferences() +} +``` + +--- + +### ❌ Missing Error Boundary at Navigation Root + +**Detection**: +```bash +grep -rn 'Stack.Screen\|Tabs.Screen' --include="*.tsx" | grep -v ErrorBoundary +rg 'NavigationContainer' --type tsx | grep -B5 -A10 'NavigationContainer' +``` + +**What it looks like**: +```tsx +export default function RootLayout() { + return ( + + + + + ) +} +``` + +**Why wrong**: If `ProfileScreen` throws during render, it unwinds the entire navigation tree. With no boundary, the app white-screens. Users must force-quit. + +**Fix**: Wrap each screen's content component in an ErrorBoundary, or add a root-level boundary around the entire navigator: +```tsx +export default function RootLayout() { + return ( + } onError={captureException}> + + + + + + ) +} +``` + +--- + +### ❌ Accessing `.data` on an Unvalidated API Response + +**Detection**: +```bash +grep -rn '\.data\.' --include="*.ts" --include="*.tsx" | grep -v "\.test\." | grep "await fetch\|axios\|useFetch" +rg '(await\s+\w+\(.*\))\.data\.' --type ts +``` + +**What it looks like**: +```ts +const response = await fetch('/api/user') +const json = await response.json() +setUser(json.data.profile.name) // throws if data or profile is undefined +``` + +**Why wrong**: API contracts break. A server returns `{ error: "not found" }` instead of `{ data: { profile: ... } }`. The chain `.data.profile.name` throws `Cannot read properties of undefined (reading 'profile')` — a crash with a misleading error message. + +**Fix**: Validate the response shape before accessing nested paths, or use optional chaining with a fallback: +```ts +const json = await response.json() +if (!json.data?.profile) { + throw new Error(`Unexpected API shape: ${JSON.stringify(json).slice(0, 200)}`) +} +setUser(json.data.profile.name) +``` + +--- + +## Error-Fix Mappings + +| Error Message | Root Cause | Fix | +|---------------|------------|-----| +| `TypeError: Cannot read properties of undefined (reading 'X')` | Null/undefined accessed via property chain after API response | Add optional chaining or explicit null check before deep access | +| `Network request failed` | No network or wrong host in dev | Check `__DEV__` vs production API URL; verify device can reach the API host | +| `JSON Parse error: Unrecognized token '<'` | Server returned HTML (error page) instead of JSON | Check `res.ok` before calling `res.json()` — server returned 4xx/5xx | +| `Maximum update depth exceeded` | State setter called inside render or effect without dependency guard | Move setter into event handler or add correct deps array to `useEffect` | +| `Warning: Can't perform a React state update on an unmounted component` | Async operation completes after component unmounts | Return cleanup function from `useEffect` that cancels in-flight request | +| `Unhandled promise rejection: Error: Invariant Violation` | Native module call outside the main thread context | Move native module calls to a dedicated service, not inside callbacks | + +--- + +## Version-Specific Notes + +| Version | Change | Impact | +|---------|--------|--------| +| RN 0.71 | `Promise.allSettled` enabled by default in Hermes | Use `allSettled` instead of `all` when you want partial results on failure | +| RN 0.73 | Unhandled rejection handling improved in Hermes | Stack traces from async errors are now preserved — update Sentry sourcemap upload | +| React 18 | `startTransition` errors fall back to nearest ErrorBoundary | Transitions that throw no longer crash the whole tree | +| `@sentry/react-native` 5.0 | `Sentry.wrap(App)` deprecated — use `Sentry.init()` then `withSentry(App)` | Update app entry if using older Sentry integration pattern | + +--- + +## Detection Commands Reference + +```bash +# Find console.error used as only error reporting (not in tests) +grep -rn 'console\.error' --include="*.tsx" --include="*.ts" | grep -v "\.test\.\|\.spec\." + +# Find empty catch blocks +grep -rn 'catch\s*(.*)\s*{\s*}' --include="*.ts" --include="*.tsx" + +# Find fetch calls without .ok check +grep -rn 'await fetch\|\.json()' --include="*.ts" --include="*.tsx" | grep -v 'res\.ok\|response\.ok' + +# Find deep property access on API responses without null guards +grep -rn '\.data\.\|\.result\.' --include="*.ts" --include="*.tsx" | grep -v '\?\.' + +# Find missing ErrorBoundary around screen components (Expo Router pattern) +grep -rn 'Stack\.Screen\|Tabs\.Screen' --include="*.tsx" | grep -v 'ErrorBoundary' +``` + +--- + +## See Also + +- `rendering-patterns.md` — Text component crashes and conditional render crashes during render phase +- `state-management.md` — Stale state that causes incorrect error recovery diff --git a/agents/react-native-engineer/references/testing.md b/agents/react-native-engineer/references/testing.md new file mode 100644 index 00000000..ea6723c9 --- /dev/null +++ b/agents/react-native-engineer/references/testing.md @@ -0,0 +1,271 @@ +# Testing Reference + + +> **Scope**: React Native test setup, React Native Testing Library (RNTL) patterns, Maestro E2E, native module mocking, and Expo test configuration. +> **Version range**: React Native 0.72+, RNTL 12+, Expo SDK 50+ +> **Generated**: 2026-04-12 — verify against current @testing-library/react-native release notes + +--- + +## Overview + +React Native testing has three distinct layers: unit/component tests via RNTL, integration via jest with native module mocks, and E2E via Maestro or Detox. The most common failure mode is testing implementation details (renders, re-renders, internal state) instead of user-observable behavior. Native modules require manual mocking; RNTL's `render` does not invoke native code. + +--- + +## Pattern Table + +| Tool | Version | Use When | Avoid When | +|------|---------|----------|------------| +| `@testing-library/react-native` | `12+` | component behavior, user interactions | testing animation internals, native rendering | +| `jest-expo` | `SDK 50+` | Expo managed workflow tests | bare RN without Expo — use `react-native` preset | +| `Maestro` | `1.36+` | E2E flows on device/simulator | unit-level component logic | +| `Detox` | `20+` | E2E when Maestro yaml DSL is insufficient | simple flows — Maestro is faster to author | +| `@react-native-community/async-storage/jest/async-storage-mock` | any | mocking AsyncStorage in unit tests | leave AsyncStorage unmocked — causes silent hang | + +--- + +## Correct Patterns + +### Test User Behavior, Not Implementation + +Query by accessible labels and roles, not test IDs or internal component names. + +```tsx +import { render, fireEvent, screen } from '@testing-library/react-native' +import { LoginScreen } from './LoginScreen' + +it('submits login with valid credentials', () => { + render() + + fireEvent.changeText(screen.getByLabelText('Email'), 'user@example.com') + fireEvent.changeText(screen.getByLabelText('Password'), 'hunter2') + fireEvent.press(screen.getByRole('button', { name: 'Log in' })) + + expect(screen.getByText('Logging in...')).toBeTruthy() +}) +``` + +**Why**: `getByLabelText` and `getByRole` query what a screen reader sees. If you rename a component but keep its accessible label, the test still passes. If you rename the label, the test correctly fails. + +--- + +### Mock Native Modules at the jest Config Level + +Native modules that don't exist in the jest environment must be mocked globally — not inside individual test files — so every test file gets the mock automatically. + +```js +// jest.config.js +module.exports = { + preset: 'jest-expo', // or 'react-native' + setupFilesAfterFramework: ['./jest.setup.ts'], + moduleNameMapper: { + // mock native camera module + 'react-native-vision-camera': '/__mocks__/react-native-vision-camera.ts', + }, +} +``` + +```ts +// __mocks__/react-native-vision-camera.ts +export const Camera = jest.fn(() => null) +export const useCameraDevices = jest.fn(() => ({ back: {}, front: {} })) +``` + +**Why**: Native modules throw "Cannot read properties of null" in jest because the native bridge doesn't exist. Module-level mocks prevent test pollution across files. + +--- + +### Use `waitFor` for Async State Changes + +Avoid `setTimeout` delays. Use `waitFor` from RNTL which polls until assertion passes or timeout expires. + +```tsx +import { render, waitFor, screen } from '@testing-library/react-native' + +it('shows loaded data after fetch', async () => { + render() + + // Wrong: arbitrary delay + // await new Promise(r => setTimeout(r, 500)) + + // Correct: poll until assertion passes + await waitFor(() => { + expect(screen.getByText('Jane Doe')).toBeTruthy() + }) +}) +``` + +**Why**: Arbitrary delays make tests either flaky (too short) or slow (too long). `waitFor` exits as soon as the assertion passes, bounding wait time without hardcoding delays. + +--- + +### Set Up AsyncStorage Mock Globally + +AsyncStorage calls that hit the real module in jest hang indefinitely with no error. + +```ts +// jest.setup.ts +import mockAsyncStorage from '@react-native-async-storage/async-storage/jest/async-storage-mock' +jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage) +``` + +**Why**: The real AsyncStorage module requires native bridge. Without the mock, `getItem`/`setItem` calls return `Promise` — the test hangs until jest timeout kills it. + +--- + +## Anti-Pattern Catalog + +### ❌ Querying by `testID` Instead of Accessible Attributes + +**Detection**: +```bash +grep -rn 'getByTestId\|findByTestId' --include="*.test.tsx" --include="*.spec.tsx" +rg 'getByTestId|findByTestId' --type tsx +``` + +**What it looks like**: +```tsx +const button = screen.getByTestId('submit-button') +fireEvent.press(button) +``` + +**Why wrong**: `testID` is invisible to users and screen readers. Tests using it break if the component is refactored even when behavior is unchanged. They also don't verify accessibility — a button with no label passes the test but fails assistive technology users. + +**Fix**: +```tsx +const button = screen.getByRole('button', { name: 'Submit' }) +fireEvent.press(button) +``` + +**Version note**: RNTL 7+ requires the component to have `accessible={true}` or a role for `getByRole` to find it. Add `accessibilityRole="button"` to `Pressable` components. + +--- + +### ❌ Importing from `react-native` Instead of `@testing-library/react-native` + +**Detection**: +```bash +grep -rn "from 'react-native'" --include="*.test.tsx" --include="*.spec.tsx" | grep -v "^.*//.*from 'react-native'" +``` + +**What it looks like**: +```tsx +import { render } from 'react-native' // wrong — no render export +import { act } from 'react-native/test-utils' // old pattern +``` + +**Why wrong**: `react-native` doesn't export `render`. These imports cause `Cannot find module` errors or silently import the wrong `act` (react vs react-native differ on batching semantics). + +**Fix**: +```tsx +import { render, fireEvent, screen, act, waitFor } from '@testing-library/react-native' +``` + +--- + +### ❌ Mocking `useNavigation` Inside Individual Test Files + +**Detection**: +```bash +grep -rn "jest.mock.*navigation\|jest.mock.*router" --include="*.test.tsx" +``` + +**What it looks like**: +```tsx +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: jest.fn() }), +})) +``` + +**Why wrong**: Repeated local mocks diverge over time. When the real API adds a new field (`navigation.setOptions`, `navigation.getId`), each test file needs updating separately. Components that call multiple navigation hooks get increasingly complex local mocks. + +**Fix**: Create a single navigation mock in `__mocks__/@react-navigation/native.ts` and let jest auto-resolve it. Or use `createNavigationContainerRef` and wrap the component in a `NavigationContainer` in the test. + +```tsx +import { NavigationContainer } from '@react-navigation/native' + +function renderWithNavigation(ui: React.ReactElement) { + return render({ui}) +} +``` + +--- + +### ❌ Using Snapshots for UI Components + +**Detection**: +```bash +grep -rn 'toMatchSnapshot\|toMatchInlineSnapshot' --include="*.test.tsx" +rg 'toMatchSnapshot' --type tsx +``` + +**What it looks like**: +```tsx +it('renders correctly', () => { + const tree = render().toJSON() + expect(tree).toMatchSnapshot() +}) +``` + +**Why wrong**: Snapshot tests fail on every styling or structure change, creating update churn. They assert nothing about behavior — a component that renders the wrong text passes if the snapshot was wrong to begin with. They're especially noisy in monorepos. + +**Fix**: Test specific rendered output that reflects user-visible behavior: +```tsx +it('displays the title', () => { + render() + expect(screen.getByText('Hello')).toBeTruthy() +}) +``` + +--- + +## Error-Fix Mappings + +| Error Message | Root Cause | Fix | +|---------------|------------|-----| +| `Invariant Violation: TurboModuleRegistry.getEnforcing(...)` | Native module accessed in jest without mock | Add module to `moduleNameMapper` in jest.config or mock in setupFilesAfterFramework | +| `Unable to find an element with the text: ...` | Component not yet rendered or async data not resolved | Wrap assertion in `await waitFor(...)` | +| `Warning: An update to ... inside a test was not wrapped in act(...)` | State update triggered after test ended or outside act | Use `await act(async () => { ... })` around the trigger, or use `waitFor` | +| `Cannot find module '@testing-library/react-native'` | Package not installed | `bun add -D @testing-library/react-native` or `npm install -D @testing-library/react-native` | +| `Element type is invalid: expected a string or class/function but got undefined` | Mocked module returns wrong shape | Check `__mocks__` file — default export may be missing or named exports wrong | +| `jest did not exit one second after the test run has completed` | Unmocked async module (AsyncStorage, NetInfo) holds open handle | Add mock in `jest.setup.ts` for all native async modules | + +--- + +## Version-Specific Notes + +| Version | Change | Impact | +|---------|--------|--------| +| RNTL 12.0 | `userEvent` API added — simulates real user gestures, not synthetic events | Prefer `userEvent.press()` over `fireEvent.press()` for interaction fidelity | +| RNTL 12.4 | `screen` query object promoted to stable — no need to destructure `render()` return | Use `screen.getByText()` instead of `const { getByText } = render()` | +| Expo SDK 50 | `jest-expo` preset updated to support new architecture (JSI) | If tests hang after SDK 50 upgrade, check `jest-expo` version matches SDK | +| RN 0.73 | New Architecture (Fabric) enabled by default in new projects | Some community library mocks break under Fabric — check library's jest setup docs | + +--- + +## Detection Commands Reference + +```bash +# Find testID queries that should be role/label queries +grep -rn 'getByTestId\|findByTestId' --include="*.test.tsx" --include="*.spec.tsx" + +# Find snapshot tests in component files +grep -rn 'toMatchSnapshot\|toMatchInlineSnapshot' --include="*.test.tsx" + +# Find unmocked native module patterns +grep -rn 'jest.mock.*native' --include="*.test.tsx" | grep -v "__mocks__" + +# Find setTimeout used as async delay in tests (should use waitFor) +grep -rn 'setTimeout.*[0-9]' --include="*.test.tsx" --include="*.spec.tsx" + +# Find old act import pattern +grep -rn "from 'react-native/test-utils'\|from 'react-test-renderer'" --include="*.test.tsx" +``` + +--- + +## See Also + +- `rendering-patterns.md` — Text component rules that affect what RNTL can query +- `list-performance.md` — FlashList/LegendList require specific test setup for virtualization