DEV Community

Cover image for 3 React Native Bugs That Crashed on Device but Passed Every Test
Diven Rastdus
Diven Rastdus

Posted on • Originally published at astraedus.dev

3 React Native Bugs That Crashed on Device but Passed Every Test

Your test suite is green. TypeScript is happy. The web preview looks perfect.

Then you install the APK on a real phone and the app crashes before the splash screen finishes.

I shipped an Expo SDK 56 app to Google Play internal testing last week. Three separate bugs made it through 376 passing tests, clean tsc, and a working web export. None of the three involved broken logic. The code was correct. The runtime wasn't what I thought it was.

Each bug only surfaced on a physical Android device running Hermes. Here's what they were, why they slipped through, and the fixes.

Bug 1: The Gold Button That Turned Grey

My app uses a theme system. A configureTheme() function mutates a shared Colors object at runtime, and components read from it via StyleSheet.create.

The problem: my main CTA button rendered grey (RGB 231,231,231) instead of gold (RGB 213,195,139) on every screen that used the shared Button component. But screen-specific styles showed the correct gold.

Root cause: JavaScript module evaluation order.

// app/_layout.tsx (BROKEN version)
import { Button } from '@core/ui';        // Step 1: Button.tsx evaluates
import { configureTheme } from '@core/constants';
import { MY_BRAND } from '@app/config/theme';

configureTheme(MY_BRAND);                 // Step 3: Colors mutated... too late
Enter fullscreen mode Exit fullscreen mode

When _layout.tsx imports @core/ui, JavaScript evaluates Button.tsx immediately. Button.tsx runs StyleSheet.create at module scope, which copies the color string by value. At that point, configureTheme() hasn't run yet, so the button captures the neutral fallback color.

Screen-level styles worked fine because route components are lazily imported after configureTheme() runs.

Why tests missed it: Jest evaluates all modules fresh per test file. Fast Refresh already mutated the Colors singleton from a previous render. Only a cold start on a real device evaluates modules in the exact import order of your entry point.

The fix: Create a bootstrap file that runs configureTheme at module scope, and import it first.

// config/themeBootstrap.ts
import { configureTheme } from '@core/constants';
import { MY_BRAND } from './theme';

configureTheme(MY_BRAND);  // Runs at module-eval time
Enter fullscreen mode Exit fullscreen mode
// app/_layout.tsx (FIXED)
import './config/themeBootstrap';         // Step 1: theme configured
import { Button } from '@core/ui';        // Step 2: Button captures real colors
Enter fullscreen mode Exit fullscreen mode

Rule: Any component that bakes brand colors into StyleSheet.create must be imported after configureTheme runs. Or resolve colors at render time as inline styles.

Bug 2: The Environment Variable That Existed Until It Didn't

The app crashed on launch with a clear error: EXPO_PUBLIC_SUPABASE_URL is required. But the .env file had the value. The dev server worked fine. npx expo export --platform web bundled without errors.

Root cause: Metro's static replacement only works with dot notation.

// storage/supabase.ts (BROKEN)
function readRequiredEnv(name: string): string {
  const value = process.env[name]?.trim();  // Dynamic bracket access
  if (!value) throw new Error(`${name} is required`);
  return value;
}

const SUPABASE_URL = readRequiredEnv('EXPO_PUBLIC_SUPABASE_URL');
Enter fullscreen mode Exit fullscreen mode

Metro (Expo's bundler) statically replaces process.env.EXPO_PUBLIC_SUPABASE_URL with the literal string value at bundle time. It's a compile-time text substitution, not a runtime lookup.

process.env[name] is dynamic. Metro can't know what name will be at runtime, so it leaves it as-is. In the bundled Hermes bytecode, process.env is an empty object. The dynamic lookup returns undefined, and the app throws.

Why tests missed it: Node.js has real process.env at runtime. Your test runner loads values from .env files into the actual process environment. Metro's static replacement is a bundler concern that only affects the production artifact.

The fix: Static dot notation. No abstraction.

// storage/supabase.ts (FIXED)
const SUPABASE_URL = process.env.EXPO_PUBLIC_SUPABASE_URL?.trim();
const SUPABASE_ANON_KEY = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY?.trim();

if (!SUPABASE_URL) {
  throw new Error('EXPO_PUBLIC_SUPABASE_URL is required');
}
Enter fullscreen mode Exit fullscreen mode

Rule: In Expo/React Native, never wrap process.env.EXPO_PUBLIC_* in a helper function that uses dynamic access. The DRY instinct is wrong here. Each env var needs its own static process.env.EXPO_PUBLIC_X line.

Bug 3: The Missing Crypto API

The app launched fine. Onboarding worked. Then the user navigated to the chat screen and got a ReferenceError: crypto is not defined.

Root cause: The uuid package calls crypto.getRandomValues() internally. Hermes (React Native's JavaScript engine) doesn't ship the Web Crypto API.

  • Node.js: has crypto globally since v19. Tests pass.
  • Chrome/Safari (web preview): has crypto on window. Web export works.
  • Hermes on Android: no crypto object. Crash.
ReferenceError: Property 'crypto' doesn't exist
    at v4 (uuid/dist/esm-browser/native.js:1:15)
    at ChatScreen (screens/companion.tsx:42:18)
Enter fullscreen mode Exit fullscreen mode

The fix: One polyfill import at the top of your app entry, before anything else.

npm install react-native-get-random-values
Enter fullscreen mode Exit fullscreen mode
// app/_layout.tsx
// This MUST be the first import. It patches globalThis.crypto
// before uuid or any other Web Crypto consumer evaluates.
import 'react-native-get-random-values';

import { useEffect } from 'react';
import { Stack } from 'expo-router';
// ... rest of imports
Enter fullscreen mode Exit fullscreen mode

Rule: If any dependency uses crypto.getRandomValues() (uuid, nanoid, webcrypto-based auth libraries), add this polyfill. It's not optional on Hermes.

The Pattern

All three bugs share the same shape:

Bug Dev/Test Web Export Real Device
Theme freeze Gold (Fast Refresh) Gold (lazy eval) Grey (cold start)
Dynamic env Works (real process.env) Works (inlined) Crash (empty object)
Missing crypto Works (Node has it) Works (browser has it) Crash (Hermes lacks it)

Tests run in Node. Web preview runs in Chrome. Neither environment matches Hermes on Android. Three different reasons, but the same gap: your test environment is lying about what the production runtime can do.

What I Do Now

After these three burned a full day each, I added a mandatory step before any release build:

  1. Cold-start the app on a real device. Not Fast Refresh. Kill the process, relaunch. This catches module-order bugs like the theme freeze.

  2. Test the EAS build artifact, not the dev server. The dev server has real process.env. The production bundle has static replacements. They behave differently.

  3. Install a crash monitoring SDK early. If you ship to testers without crash reporting, you're running blind. The crypto bug only triggered on a specific navigation path. A crash report with the Hermes stack trace would have cut debugging time from hours to minutes.

Your test suite verifies your logic. It doesn't verify your runtime. The device is the only source of truth.

Top comments (0)